diff --git a/.dockerignore b/.dockerignore index d5369692cbe..66f902759ca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,9 +11,7 @@ *.md !deploy/DOCKER.md docs/ -# admin-compliance gate (LegalDocumentView.vue) build-time imports -# docs/legal/*.md?raw into the frontend bundle; keep that subtree in the -# Docker build context even though docs/ is otherwise excluded. + !docs/legal/ !docs/legal/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..1944f41e437 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +## Unreleased + +### Fix: Docker 构建失败(docs/legal 文件缺失) + +- **Dockerfile**: 添加 `COPY docs/legal/ /app/docs/legal/`,解决前端 `LegalDocumentView.vue` 通过相对路径引用 `docs/legal/admin-compliance.*.md` 在容器内找不到文件导致 vite build 失败的问题。 +- **.dockerignore**: 添加 `!docs/legal/` 和 `!docs/legal/*.md` 例外规则,使 `docs/legal/` 目录不被全局的 `docs/` 和 `*.md` 排除规则过滤掉。 +- **删除 `frontend/pnpm-workspace.yaml`**: 该文件由本地 pnpm v11 的 `approve-builds` 生成,与 Dockerfile 中 pnpm@9 不兼容(pnpm@9 要求 workspace 文件必须包含 `packages` 字段),导致 `pnpm install` 和 `pnpm run build` 报 "packages field missing or empty"。 + +- Added a config script download action to each API key row, matching the existing API key action area. +- Added a config script dropdown with Codex CLI, Claude Code, and OpenCode options, styled after the provided reference image. +- Added automatic OS detection so macOS downloads `.sh` scripts and Windows downloads `.bat` scripts. +- Added config script generation for Codex CLI, Claude Code, and OpenCode with the current API endpoint and API key injected into the generated files. +- Set the generated script site name to `look2eye`. +- Added Chinese and English i18n text for the config script button, menu, hints, and download states. +- Added focused unit coverage for config script generation and client availability rules. + + +本地 docker 部署 +export http_proxy=http://127.0.0.1:7890 +export https_proxy=http://127.0.0.1:7890 +export HTTP_PROXY=http://127.0.0.1:7890 +export HTTPS_PROXY=http://127.0.0.1:7890 +docker compose -f docker-compose.dev.yml up --build -d + +上传自己的 docker hub +export http_proxy=http://127.0.0.1:7890 +export https_proxy=http://127.0.0.1:7890 +export HTTP_PROXY=http://127.0.0.1:7890 +export HTTPS_PROXY=http://127.0.0.1:7890 +docker buildx build --platform linux/amd64 -t docker.io/doctor11ma/sub2api:latest -t docker.io/doctor11ma/sub2api:v0.1.138 --push . + +feat +上传自己的 docker hub +export http_proxy=http://127.0.0.1:7890 +export https_proxy=http://127.0.0.1:7890 +export HTTP_PROXY=http://127.0.0.1:7890 +export HTTPS_PROXY=http://127.0.0.1:7890 +docker buildx build --platform linux/amd64 -t docker.io/doctor11ma/sub2api:v0.1.139feat --push . + diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index b9a9a3e80eb..3ccafee229d 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -89,6 +89,7 @@ func provideCleanup( emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, usageRecordWorkerPool *service.UsageRecordWorkerPool, + archiveService *service.ArchiveService, subscriptionService *service.SubscriptionService, oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, @@ -207,6 +208,12 @@ func provideCleanup( } return nil }}, + {"ArchiveService", func() error { + if archiveService != nil { + archiveService.Stop() + } + return nil + }}, {"OAuthService", func() error { oauth.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a412563a6c0..1557a6d0686 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -250,10 +250,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { complianceHandler := admin.NewComplianceHandler(settingService) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) + archiveService := service.NewArchiveService(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) - openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig) + gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, archiveService, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) + openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, archiveService, errorPassthroughService, contentModerationService, opsService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService) @@ -280,7 +281,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, archiveService, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService,grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher) + //v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher) application := &Application{ Server: httpServer, Cleanup: v, @@ -326,6 +328,7 @@ func provideCleanup( emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, usageRecordWorkerPool *service.UsageRecordWorkerPool, + archiveService *service.ArchiveService, subscriptionService *service.SubscriptionService, oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, @@ -443,6 +446,12 @@ func provideCleanup( } return nil }}, + {"ArchiveService", func() error { + if archiveService != nil { + archiveService.Stop() + } + return nil + }}, {"OAuthService", func() error { oauth.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index ef74cb4a2d4..9b45259c5b8 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -69,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { emailQueueSvc, billingCacheSvc, &service.UsageRecordWorkerPool{}, + &service.ArchiveService{}, &service.SubscriptionService{}, oauthSvc, openAIOAuthSvc, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 99fedb5b1c7..f787764f8c2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -93,6 +93,7 @@ type Config struct { Gemini GeminiConfig `mapstructure:"gemini"` Update UpdateConfig `mapstructure:"update"` Idempotency IdempotencyConfig `mapstructure:"idempotency"` + Archive ArchiveConfig `mapstructure:"archive"` } type LogConfig struct { @@ -1017,7 +1018,31 @@ type GatewayUsageRecordConfig struct { AutoScaleCooldownSeconds int `mapstructure:"auto_scale_cooldown_seconds"` } -// TLSFingerprintConfig TLS指纹伪装配置 +// ArchiveConfig 请求/响应全量落盘归档配置。 +// 默认关闭(opt-in),开启后异步把每次请求体+响应体写入 zstd 压缩的 JSONL 分片。 +type ArchiveConfig struct { + // Enabled: 是否启用归档(默认 false) + Enabled bool `mapstructure:"enabled"` + // Dir: 归档目录,空则默认 /archive + Dir string `mapstructure:"dir"` + // MaxShardSizeMB: 单分片压缩后大小上限(MB),超过切片 + MaxShardSizeMB int `mapstructure:"max_shard_size_mb"` + // QueueMaxItems: 有界队列最大条数 + QueueMaxItems int `mapstructure:"queue_max_items"` + // QueueMaxBytes: 有界队列最大字节数(内存预算) + QueueMaxBytes int64 `mapstructure:"queue_max_bytes"` + // MaxResponseBytes: 单条记录响应体捕获上限(字节),超出截断 + MaxResponseBytes int `mapstructure:"max_response_bytes"` + // CompressionLevel: zstd 级别 1-4(1 最快 / 3 默认 / 4 最高压缩比) + CompressionLevel int `mapstructure:"compression_level"` + // FlushIntervalMs: 周期 flush 间隔(毫秒) + FlushIntervalMs int `mapstructure:"flush_interval_ms"` + // MinFreeDiskGB: 归档分区剩余空间低于该值则停写(防写爆磁盘),0 关闭检查 + MinFreeDiskGB int `mapstructure:"min_free_disk_gb"` + // IPHashSalt: 客户端 IP 哈希盐;空则在归档目录自动生成持久盐 + IPHashSalt string `mapstructure:"ip_hash_salt"` +} + // 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端 type TLSFingerprintConfig struct { // Enabled: 是否全局启用TLS指纹功能 @@ -1966,6 +1991,18 @@ func setDefaults() { viper.SetDefault("gateway.usage_record.auto_scale_cooldown_seconds", 10) viper.SetDefault("gateway.user_group_rate_cache_ttl_seconds", 30) viper.SetDefault("gateway.models_list_cache_ttl_seconds", 15) + + // 请求/响应归档(默认关闭,opt-in) + viper.SetDefault("archive.enabled", false) + viper.SetDefault("archive.dir", "") + viper.SetDefault("archive.max_shard_size_mb", 512) + viper.SetDefault("archive.queue_max_items", 4096) + viper.SetDefault("archive.queue_max_bytes", 256*1024*1024) + viper.SetDefault("archive.max_response_bytes", 16*1024*1024) + viper.SetDefault("archive.compression_level", 3) + viper.SetDefault("archive.flush_interval_ms", 1500) + viper.SetDefault("archive.min_free_disk_gb", 10) + viper.SetDefault("archive.ip_hash_salt", "") // TLS指纹伪装配置(默认关闭,需要账号级别单独启用) // 用户消息串行队列默认值 viper.SetDefault("gateway.user_message_queue.enabled", false) @@ -2023,6 +2060,23 @@ func (c *Config) Validate() error { default: return fmt.Errorf("log.format must be one of: json/console") } + if c.Archive.Enabled { + if c.Archive.MaxShardSizeMB <= 0 { + return fmt.Errorf("archive.max_shard_size_mb must be positive when archive is enabled") + } + if c.Archive.MaxResponseBytes <= 0 { + return fmt.Errorf("archive.max_response_bytes must be positive when archive is enabled") + } + if c.Archive.CompressionLevel < 1 || c.Archive.CompressionLevel > 4 { + return fmt.Errorf("archive.compression_level must be between 1 and 4") + } + if c.Archive.FlushIntervalMs <= 0 { + return fmt.Errorf("archive.flush_interval_ms must be positive") + } + if c.Archive.QueueMaxItems <= 0 { + return fmt.Errorf("archive.queue_max_items must be positive") + } + } switch c.Log.StacktraceLevel { case "none", "error", "fatal": case "": diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index b20d9ef652a..8fe8f48094a 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -47,6 +47,7 @@ type GatewayHandler struct { usageService *service.UsageService apiKeyService *service.APIKeyService usageRecordWorkerPool *service.UsageRecordWorkerPool + archiveService *service.ArchiveService errorPassthroughService *service.ErrorPassthroughService contentModerationService *service.ContentModerationService concurrencyHelper *ConcurrencyHelper @@ -68,6 +69,7 @@ func NewGatewayHandler( usageService *service.UsageService, apiKeyService *service.APIKeyService, usageRecordWorkerPool *service.UsageRecordWorkerPool, + archiveService *service.ArchiveService, errorPassthroughService *service.ErrorPassthroughService, contentModerationService *service.ContentModerationService, userMsgQueueService *service.UserMessageQueueService, @@ -102,6 +104,7 @@ func NewGatewayHandler( usageService: usageService, apiKeyService: apiKeyService, usageRecordWorkerPool: usageRecordWorkerPool, + archiveService: archiveService, errorPassthroughService: errorPassthroughService, contentModerationService: contentModerationService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval), @@ -520,6 +523,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.archiveCapture(c, body, result, account, apiKey) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -950,6 +954,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey) + h.archiveCapture(c, attemptParsedReq.Body.Bytes(), result, account, currentAPIKey) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -2169,6 +2174,42 @@ func (h *GatewayHandler) maybeLogCompatibilityFallbackMetrics(reqLog *zap.Logger ) } +// archiveCapture 在归档启用时构建并提交一条请求/响应归档记录(非阻塞、可丢弃)。 +// 须在 Forward 完成、响应已写完后调用,此时捕获中间件已拿到完整响应体。 +func (h *GatewayHandler) archiveCapture(c *gin.Context, body []byte, result *service.ForwardResult, account *service.Account, apiKey *service.APIKey) { + if h == nil || h.archiveService == nil || !h.archiveService.Enabled() || result == nil { + return + } + respBody, truncated, _ := middleware2.GetArchivedResponse(c) + var accountID int64 + if account != nil { + accountID = account.ID + } + var userID, apiKeyID int64 + if apiKey != nil { + userID = apiKey.UserID + apiKeyID = apiKey.ID + } + h.archiveService.Capture(service.ArchiveInput{ + Body: body, + RespBody: respBody, + RespTruncated: truncated, + ReqHeaders: c.Request.Header, + RespHeaders: c.Writer.Header(), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + Model: result.Model, + InboundEndpoint: GetInboundEndpoint(c), + Stream: result.Stream, + Status: c.Writer.Status(), + DurationMs: result.Duration.Milliseconds(), + RequestID: result.RequestID, + ClientIP: ip.GetClientIP(c), + Usage: result.Usage, + }) +} + func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task service.UsageRecordTask) { if task == nil { return diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index d0ecc01e6a0..307ddc15a80 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -287,6 +287,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.archiveCapture(c, body, result, account, apiKey) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 4a8d7521935..f718e1dca74 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -266,6 +266,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.archiveCapture(c, body, result, account, apiKey) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 86be1062e90..68d742269bb 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -524,6 +524,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.archiveCapture(c, body, result, account, apiKey) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ Result: result, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 7f097afa4bd..3b3bf24b47f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -32,6 +32,7 @@ type OpenAIGatewayHandler struct { billingCacheService *service.BillingCacheService apiKeyService *service.APIKeyService usageRecordWorkerPool *service.UsageRecordWorkerPool + archiveService *service.ArchiveService errorPassthroughService *service.ErrorPassthroughService contentModerationService *service.ContentModerationService opsService *service.OpsService @@ -121,6 +122,7 @@ func NewOpenAIGatewayHandler( billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, usageRecordWorkerPool *service.UsageRecordWorkerPool, + archiveService *service.ArchiveService, errorPassthroughService *service.ErrorPassthroughService, contentModerationService *service.ContentModerationService, opsService *service.OpsService, @@ -139,6 +141,7 @@ func NewOpenAIGatewayHandler( billingCacheService: billingCacheService, apiKeyService: apiKeyService, usageRecordWorkerPool: usageRecordWorkerPool, + archiveService: archiveService, errorPassthroughService: errorPassthroughService, contentModerationService: contentModerationService, opsService: opsService, @@ -528,6 +531,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 + h.archiveCapture(c, body, result, account, apiKey) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -943,6 +947,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.archiveCapture(c, body, result, account, apiKey) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -1547,6 +1552,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) + //upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + // 注:OpenAI Responses WebSocket 路径不做归档——连接被 Hijack, + // 捕获中间件无法看到 WS 帧,且逐轮 payload 不在此作用域内。 upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) cyberBlocked := service.GetOpsCyberPolicy(c) != nil @@ -1751,6 +1759,46 @@ func getContextInt64(c *gin.Context, key string) (int64, bool) { } } +// archiveCapture 在归档启用时构建并提交一条请求/响应归档记录(非阻塞、可丢弃)。 +func (h *OpenAIGatewayHandler) archiveCapture(c *gin.Context, body []byte, result *service.OpenAIForwardResult, account *service.Account, apiKey *service.APIKey) { + if h == nil || h.archiveService == nil || !h.archiveService.Enabled() || result == nil { + return + } + respBody, truncated, _ := middleware2.GetArchivedResponse(c) + var accountID int64 + if account != nil { + accountID = account.ID + } + var userID, apiKeyID int64 + if apiKey != nil { + userID = apiKey.UserID + apiKeyID = apiKey.ID + } + h.archiveService.Capture(service.ArchiveInput{ + Body: body, + RespBody: respBody, + RespTruncated: truncated, + ReqHeaders: c.Request.Header, + RespHeaders: c.Writer.Header(), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + Model: result.Model, + InboundEndpoint: GetInboundEndpoint(c), + Stream: result.Stream, + Status: c.Writer.Status(), + DurationMs: result.Duration.Milliseconds(), + RequestID: result.RequestID, + ClientIP: ip.GetClientIP(c), + Usage: service.ClaudeUsage{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheReadInputTokens: result.Usage.CacheReadInputTokens, + CacheCreationInputTokens: result.Usage.CacheCreationInputTokens, + }, + }) +} + func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, task service.UsageRecordTask) { if task == nil { return diff --git a/backend/internal/server/middleware/archive_capture.go b/backend/internal/server/middleware/archive_capture.go new file mode 100644 index 00000000000..f08f13343bd --- /dev/null +++ b/backend/internal/server/middleware/archive_capture.go @@ -0,0 +1,90 @@ +package middleware + +import ( + "bytes" + + "github.com/gin-gonic/gin" +) + +const archiveCaptureContextKey = "archive_capture_writer" + +// archiveCaptureWriter 包裹 gin.ResponseWriter,在转发响应给客户端的同时 +// 把响应字节 tee 进一个有上限的 buffer。 +// +// 关键:通过接口嵌入 gin.ResponseWriter,Flush/Size/Status/Hijack/CloseNotify +// 等方法自动透传——流式 SSE 的 w.(http.Flusher) 断言与 c.Writer.Size() +// 的 failover 判断都不受影响。 +type archiveCaptureWriter struct { + gin.ResponseWriter + limit int + buf bytes.Buffer + truncated bool +} + +func (w *archiveCaptureWriter) capture(p []byte) { + if w.limit <= 0 { + return + } + if w.buf.Len() >= w.limit { + w.truncated = true + return + } + remaining := w.limit - w.buf.Len() + if len(p) > remaining { + _, _ = w.buf.Write(p[:remaining]) + w.truncated = true + } else { + _, _ = w.buf.Write(p) + } +} + +func (w *archiveCaptureWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + if n > 0 { + w.capture(b[:n]) + } + return n, err +} + +func (w *archiveCaptureWriter) WriteString(s string) (int, error) { + n, err := w.ResponseWriter.WriteString(s) + if n > 0 { + w.capture([]byte(s[:n])) + } + return n, err +} + +// ArchiveCaptureMiddleware 在网关路由上捕获完整响应体(含流式)。 +// 仅在归档启用时挂载;maxBytes 为单请求捕获上限,超出截断。 +func ArchiveCaptureMiddleware(maxBytes int) gin.HandlerFunc { + if maxBytes <= 0 { + maxBytes = 16 << 20 + } + return func(c *gin.Context) { + original := c.Writer + w := &archiveCaptureWriter{ResponseWriter: original, limit: maxBytes} + c.Writer = w + c.Set(archiveCaptureContextKey, w) + defer func() { + // 还原原始 writer,避免外层中间件观察到包裹器。 + if c.Writer == w { + c.Writer = original + } + }() + c.Next() + } +} + +// GetArchivedResponse 返回中间件捕获到的响应体及是否被截断。 +// 返回的字节切片底层引用 buffer,调用方应在使用时自行拷贝(如 string(...))。 +func GetArchivedResponse(c *gin.Context) (body []byte, truncated bool, ok bool) { + v, exists := c.Get(archiveCaptureContextKey) + if !exists { + return nil, false, false + } + w, good := v.(*archiveCaptureWriter) + if !good || w == nil { + return nil, false, false + } + return w.buf.Bytes(), w.truncated, true +} diff --git a/backend/internal/server/middleware/archive_capture_integration_test.go b/backend/internal/server/middleware/archive_capture_integration_test.go new file mode 100644 index 00000000000..10b56a7fbd4 --- /dev/null +++ b/backend/internal/server/middleware/archive_capture_integration_test.go @@ -0,0 +1,194 @@ +package middleware_test + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + mw "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/klauspost/compress/zstd" +) + +// TestArchiveEndToEndStreaming 端到端:真实 gin + 捕获中间件 + ArchiveService, +// 发一次流式 SSE 请求,校验: +// - Flusher 被捕获 writer 保留(流式可正常 flush) +// - 客户端收到的响应字节完整无损 +// - 落盘 .zst 解压后请求体/响应体完整 +// - 密钥(Authorization)与原始 IP 绝不入档,IP 仅哈希 +func TestArchiveEndToEndStreaming(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.Archive = config.ArchiveConfig{ + Enabled: true, + Dir: dir, + MaxShardSizeMB: 512, + QueueMaxItems: 128, + QueueMaxBytes: 64 << 20, + MaxResponseBytes: 1 << 20, + CompressionLevel: 3, + FlushIntervalMs: 50, + } + svc := service.NewArchiveService(cfg) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(mw.ArchiveCaptureMiddleware(cfg.Archive.MaxResponseBytes)) + r.POST("/v1/messages", func(c *gin.Context) { + body, _ := io.ReadAll(c.Request.Body) + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cf-Ray", "test-ray-123") + c.Writer.Header().Set("Set-Cookie", "sess=should-be-stripped") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + t.Error("capture writer must preserve http.Flusher for SSE") + } + for _, chunk := range []string{"data: {\"delta\":\"hi\"}\n\n", "data: [DONE]\n\n"} { + if _, err := io.WriteString(c.Writer, chunk); err != nil { + t.Fatalf("stream write: %v", err) + } + if ok { + flusher.Flush() + } + } + + // 落点:捕获完整响应并归档(模拟 handler 在 Forward 后的 archiveCapture)。 + respBody, trunc, found := mw.GetArchivedResponse(c) + if !found { + t.Error("GetArchivedResponse must find the capture writer") + } + svc.Capture(service.ArchiveInput{ + Body: body, + RespBody: respBody, + RespTruncated: trunc, + ReqHeaders: c.Request.Header, + RespHeaders: c.Writer.Header(), + UserID: 7, + APIKeyID: 42, + AccountID: 99, + Model: "claude-opus-4-8", + InboundEndpoint: "/v1/messages", + Stream: true, + Status: c.Writer.Status(), + ClientIP: "203.0.113.7", + Usage: service.ClaudeUsage{InputTokens: 12, OutputTokens: 3, CacheReadInputTokens: 8}, + }) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", + strings.NewReader(`{"model":"claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Authorization", "Bearer sk-MUST-NOT-LEAK") + req.Header.Set("X-Api-Key", "sk-ALSO-SECRET") + req.Header.Set("User-Agent", "claude-cli/9.9") + req.Header.Set("X-Stainless-Os", "Linux") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // 1) 客户端收到的响应必须与流式写出的内容逐字节一致。 + wantClient := "data: {\"delta\":\"hi\"}\n\ndata: [DONE]\n\n" + if w.Body.String() != wantClient { + t.Fatalf("client response mismatch:\n got=%q\nwant=%q", w.Body.String(), wantClient) + } + + svc.Stop() + + // 2) 落盘 .zst 解压回放。 + shard := findOneShard(t, dir) + rec := decodeFirstRecord(t, shard) + + pretty, _ := json.MarshalIndent(rec, "", " ") + t.Logf("归档落盘记录(解压后):\n%s", pretty) + + if got := rec["response_body"]; got != wantClient { + t.Errorf("archived response_body mismatch: %q", got) + } + if _, ok := rec["request_body"]; !ok { + t.Error("request_body missing") + } + ipHash, _ := rec["ip_hash"].(string) + if ipHash == "" || strings.Contains(ipHash, "203.0.113.7") { + t.Errorf("ip must be hashed, got %q", ipHash) + } + + // 3) 隐私红线:整条记录里绝不出现密钥或原始 IP。 + blob := string(pretty) + for _, secret := range []string{"sk-MUST-NOT-LEAK", "sk-ALSO-SECRET", "203.0.113.7", "should-be-stripped"} { + if strings.Contains(blob, secret) { + t.Errorf("sensitive value leaked into archive: %q", secret) + } + } + + // 4) header 白名单:保留 user-agent/x-stainless-*/cf-ray,剥离密钥/set-cookie。 + reqH, _ := rec["request_headers"].(map[string]any) + if _, ok := reqH["user-agent"]; !ok { + t.Error("user-agent should be kept") + } + if _, ok := reqH["x-stainless-os"]; !ok { + t.Error("x-stainless-os should be kept") + } + if _, ok := reqH["authorization"]; ok { + t.Error("authorization must be stripped") + } + respH, _ := rec["response_headers"].(map[string]any) + if _, ok := respH["cf-ray"]; !ok { + t.Error("cf-ray should be kept") + } + if _, ok := respH["set-cookie"]; ok { + t.Error("set-cookie must be stripped") + } +} + +func findOneShard(t *testing.T, dir string) string { + t.Helper() + var found []string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && info != nil && !info.IsDir() && strings.HasSuffix(path, ".jsonl.zst") { + found = append(found, path) + } + return nil + }) + if len(found) != 1 { + t.Fatalf("expected 1 shard, found %d: %v", len(found), found) + } + return found[0] +} + +func decodeFirstRecord(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shard: %v", err) + } + dec, err := zstd.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("zstd reader: %v", err) + } + defer dec.Close() + sc := bufio.NewScanner(dec) + sc.Buffer(make([]byte, 0, 1<<20), 16<<20) + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + var m map[string]any + if err := json.Unmarshal(line, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return m + } + t.Fatal("no record in shard") + return nil +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 95225780517..eab2ed0fe43 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -27,6 +27,13 @@ func RegisterGatewayRoutes( opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService) endpointNorm := handler.InboundEndpointMiddleware() + // 请求/响应归档捕获:仅在启用时包裹 c.Writer 捕获完整响应体; + // 未启用时为零包裹的透传(仅一次函数调用开销)。 + archiveCapture := gin.HandlerFunc(func(c *gin.Context) { c.Next() }) + if cfg.Archive.Enabled { + archiveCapture = middleware.ArchiveCaptureMiddleware(cfg.Archive.MaxResponseBytes) + } + // 未分组 Key 拦截中间件(按协议格式区分错误响应) requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter) requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter) @@ -90,6 +97,7 @@ func RegisterGatewayRoutes( gateway.Use(clientRequestID) gateway.Use(opsErrorLogger) gateway.Use(endpointNorm) + gateway.Use(archiveCapture) gateway.Use(gin.HandlerFunc(apiKeyAuth)) gateway.Use(requireGroupAnthropic) { @@ -174,6 +182,7 @@ func RegisterGatewayRoutes( gemini.Use(clientRequestID) gemini.Use(opsErrorLogger) gemini.Use(endpointNorm) + gemini.Use(archiveCapture) gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) gemini.Use(requireGroupGoogle) { @@ -191,13 +200,17 @@ func RegisterGatewayRoutes( } h.Gateway.Responses(c) } - r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler) - r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler) - r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - h.OpenAIGateway.ResponsesWebSocket(c) - }) + r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler) + r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler) + r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) == service.PlatformGrok { + rejectGrokUnsupportedEndpoint(c, "Responses WebSocket API") + return + } + h.OpenAIGateway.ResponsesWebSocket(c) + }) codexDirect := r.Group("/backend-api/codex") - codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic) + codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic) { codexDirect.POST("/responses", responsesHandler) codexDirect.POST("/responses/*subpath", responsesHandler) @@ -206,14 +219,20 @@ func RegisterGatewayRoutes( }) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform - r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if isOpenAIResponsesCompatibleGatewayPlatform(c) { - h.OpenAIGateway.ChatCompletions(c) - return - } - h.Gateway.ChatCompletions(c) - }) - r.POST("/embeddings", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) == service.PlatformGrok { + rejectGrokUnsupportedEndpoint(c, "Chat Completions API") + return + } + + if isOpenAIResponsesCompatibleGatewayPlatform(c) { + h.OpenAIGateway.ChatCompletions(c) + return + } + + h.Gateway.ChatCompletions(c) + }) + r.POST("/embeddings", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { if getGroupPlatform(c) != service.PlatformOpenAI { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ @@ -226,8 +245,8 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.Embeddings(c) }) - r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) - r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) + r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm,archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) + r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm,archiveCapture, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler) r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoGenerationHandler) r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, videoStatusHandler) @@ -240,6 +259,7 @@ func RegisterGatewayRoutes( antigravityV1.Use(clientRequestID) antigravityV1.Use(opsErrorLogger) antigravityV1.Use(endpointNorm) + antigravityV1.Use(archiveCapture) antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1.Use(gin.HandlerFunc(apiKeyAuth)) antigravityV1.Use(requireGroupAnthropic) @@ -255,6 +275,7 @@ func RegisterGatewayRoutes( antigravityV1Beta.Use(clientRequestID) antigravityV1Beta.Use(opsErrorLogger) antigravityV1Beta.Use(endpointNorm) + antigravityV1Beta.Use(archiveCapture) antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) antigravityV1Beta.Use(requireGroupGoogle) diff --git a/backend/internal/service/archive_service.go b/backend/internal/service/archive_service.go new file mode 100644 index 00000000000..b07c754fd85 --- /dev/null +++ b/backend/internal/service/archive_service.go @@ -0,0 +1,574 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/klauspost/compress/zstd" + "go.uber.org/zap" +) + +// ArchiveService 将每次请求/响应全量异步落盘归档。 +// +// 设计要点(对应调研约束): +// - 全异步、可丢弃:请求侧仅把记录塞进有界 channel,绝不阻塞热路径; +// 队列按字节+条数双限流,溢出直接丢弃并周期 WARN。 +// - 单 writer 协程串行写当前分片文件,顺序追加,对磁盘最友好。 +// - 边写边 zstd 流式压缩,明文从不落盘,CPU 平稳无尖峰。 +// - 按天目录 + 单文件,超 MaxShardSize 再切片;按天便于月末整月拉取。 +// - 磁盘水位保护:剩余空间不足时停写,防写爆 PG 同分区。 +// +// 隐私红线(schema 固定):只存哈希后的客户端 IP,不存上游域名/任何密钥; +// header 走白名单(filterArchiveHeaders)。 +type ArchiveService struct { + enabled bool + dir string + maxShardBytes int64 + maxResponseBytes int + flushInterval time.Duration + minFreeBytes uint64 + ipSalt string + queueMaxItems int + queueMaxBytes int64 + + ch chan *ArchiveRecord + queuedBytes atomic.Int64 + dropped atomic.Uint64 + written atomic.Uint64 + + lastDropLogNanos atomic.Int64 + + // 以下字段仅由单 writer 协程访问,无需加锁。 + encoderLevel zstd.EncoderLevel + enc *zstd.Encoder + file *os.File + curDay string + curShardSeq int + curUncompressed int64 + diskFull bool + writeCounter int + + cancel context.CancelFunc + doneCh chan struct{} + stopOnce sync.Once +} + +// ArchiveUsage 归档记录里的 token 用量(精简版)。 +type ArchiveUsage struct { + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cache_read,omitempty"` + CacheWrite int `json:"cache_write,omitempty"` +} + +// ArchiveRecord 单条归档记录(JSONL 一行)。 +// +// 注意:不含 upstream_endpoint、不含任何密钥 header、不含原始 IP(仅 ip_hash)。 +type ArchiveRecord struct { + Timestamp time.Time `json:"ts"` + RequestID string `json:"request_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` + APIKeyID int64 `json:"api_key_id,omitempty"` + AccountID int64 `json:"account_id,omitempty"` + Model string `json:"model,omitempty"` + InboundEndpoint string `json:"inbound_endpoint,omitempty"` + Stream bool `json:"stream"` + Status int `json:"status,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + IPHash string `json:"ip_hash,omitempty"` + Usage *ArchiveUsage `json:"usage,omitempty"` + RequestHeaders map[string]string `json:"request_headers,omitempty"` + ResponseHeaders map[string]string `json:"response_headers,omitempty"` + RequestBody json.RawMessage `json:"request_body,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + ResponseTrunc bool `json:"response_truncated,omitempty"` + + // enqueuedSize 仅供队列字节计量,不参与序列化。 + enqueuedSize int +} + +// ArchiveInput 是落点(handler)提交归档时携带的原始数据。 +// Service 内部据此构建 ArchiveRecord:过滤 header 白名单、哈希 IP。 +type ArchiveInput struct { + Body []byte + RespBody []byte + RespTruncated bool + ReqHeaders http.Header + RespHeaders http.Header + + UserID int64 + APIKeyID int64 + AccountID int64 + Model string + InboundEndpoint string + Stream bool + Status int + DurationMs int64 + RequestID string + ClientIP string + Usage ClaudeUsage +} + +// NewArchiveService 从配置构建归档服务。未启用时返回一个惰性 no-op 实例。 +func NewArchiveService(cfg *config.Config) *ArchiveService { + if cfg == nil || !cfg.Archive.Enabled { + return &ArchiveService{enabled: false} + } + ac := cfg.Archive + + dir := strings.TrimSpace(ac.Dir) + if dir == "" { + dir = filepath.Join(resolveArchiveDataDir(), "archive") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + logger.L().With(zap.String("component", "service.archive")). + Error("archive.mkdir_failed_disabled", zap.String("dir", dir), zap.Error(err)) + return &ArchiveService{enabled: false} + } + + salt := strings.TrimSpace(ac.IPHashSalt) + if salt == "" { + salt = loadOrCreateIPSalt(dir) + } + + s := &ArchiveService{ + enabled: true, + dir: dir, + maxShardBytes: int64(intOrDefault(ac.MaxShardSizeMB, 512)) * 1024 * 1024, + maxResponseBytes: intOrDefault(ac.MaxResponseBytes, 16<<20), + flushInterval: time.Duration(intOrDefault(ac.FlushIntervalMs, 1500)) * time.Millisecond, + minFreeBytes: uint64(archiveMaxInt(ac.MinFreeDiskGB, 0)) * 1024 * 1024 * 1024, + ipSalt: salt, + queueMaxItems: intOrDefault(ac.QueueMaxItems, 4096), + queueMaxBytes: ac.QueueMaxBytes, + doneCh: make(chan struct{}), + } + if s.queueMaxBytes <= 0 { + s.queueMaxBytes = 256 * 1024 * 1024 + } + s.ch = make(chan *ArchiveRecord, s.queueMaxItems) + + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + go s.run(ctx, encoderLevel(ac.CompressionLevel)) + + logger.L().With(zap.String("component", "service.archive")). + Info("archive.started", + zap.String("dir", dir), + zap.Int("max_shard_mb", ac.MaxShardSizeMB), + zap.Int("queue_max_items", s.queueMaxItems), + zap.Int64("queue_max_bytes", s.queueMaxBytes), + zap.Int("min_free_disk_gb", ac.MinFreeDiskGB), + ) + return s +} + +// Enabled 报告归档是否启用。 +func (s *ArchiveService) Enabled() bool { + return s != nil && s.enabled +} + +// Capture 由 handler 落点调用:构建记录并非阻塞提交。 +func (s *ArchiveService) Capture(in ArchiveInput) { + if !s.Enabled() { + return + } + rec := &ArchiveRecord{ + Timestamp: time.Now(), + RequestID: in.RequestID, + UserID: in.UserID, + APIKeyID: in.APIKeyID, + AccountID: in.AccountID, + Model: in.Model, + InboundEndpoint: in.InboundEndpoint, + Stream: in.Stream, + Status: in.Status, + DurationMs: in.DurationMs, + IPHash: hashClientIP(in.ClientIP, s.ipSalt), + RequestHeaders: filterArchiveHeaders(in.ReqHeaders, archiveReqHeaderExact, archiveReqHeaderPrefix), + ResponseHeaders: filterArchiveHeaders(in.RespHeaders, archiveRespHeaderExact, archiveRespHeaderPrefix), + ResponseTrunc: in.RespTruncated, + } + + if u := in.Usage; u != (ClaudeUsage{}) { + rec.Usage = &ArchiveUsage{ + Input: u.InputTokens, + Output: u.OutputTokens, + CacheRead: u.CacheReadInputTokens, + CacheWrite: u.CacheCreationInputTokens, + } + } + + if len(in.Body) > 0 { + if json.Valid(in.Body) { + rec.RequestBody = json.RawMessage(in.Body) + } else if b, err := json.Marshal(string(in.Body)); err == nil { + rec.RequestBody = b + } + } + if len(in.RespBody) > 0 { + resp := in.RespBody + if len(resp) > s.maxResponseBytes { + resp = resp[:s.maxResponseBytes] + rec.ResponseTrunc = true + } + rec.ResponseBody = string(resp) + } + + rec.enqueuedSize = len(rec.RequestBody) + len(rec.ResponseBody) + 512 + s.submit(rec) +} + +func (s *ArchiveService) submit(rec *ArchiveRecord) { + if s.queueMaxBytes > 0 && s.queuedBytes.Load()+int64(rec.enqueuedSize) > s.queueMaxBytes { + s.drop("bytes") + return + } + select { + case s.ch <- rec: + s.queuedBytes.Add(int64(rec.enqueuedSize)) + default: + s.drop("full") + } +} + +func (s *ArchiveService) drop(reason string) { + s.dropped.Add(1) + now := time.Now().UnixNano() + last := s.lastDropLogNanos.Load() + // 最多每 5s 打一条,避免刷屏。 + if now-last < int64(5*time.Second) { + return + } + if !s.lastDropLogNanos.CompareAndSwap(last, now) { + return + } + logger.L().With(zap.String("component", "service.archive")). + Warn("archive.record_dropped", zap.String("reason", reason), zap.Uint64("dropped_total", s.dropped.Load())) +} + +// Stop 优雅停机:停止接收、排空队列、收尾当前分片。 +func (s *ArchiveService) Stop() { + if !s.Enabled() { + return + } + s.stopOnce.Do(func() { + if s.cancel != nil { + s.cancel() + } + <-s.doneCh + }) +} + +func (s *ArchiveService) run(ctx context.Context, level zstd.EncoderLevel) { + defer close(s.doneCh) + s.encoderLevel = level + ticker := time.NewTicker(s.flushInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + s.drain() + s.closeShard() + return + case rec := <-s.ch: + s.queuedBytes.Add(-int64(rec.enqueuedSize)) + s.writeRecord(rec) + case <-ticker.C: + s.flush() + s.maybeRotateBySize() + } + } +} + +// drain 在停机时把 channel 里剩余记录写完。 +func (s *ArchiveService) drain() { + for { + select { + case rec := <-s.ch: + s.queuedBytes.Add(-int64(rec.enqueuedSize)) + s.writeRecord(rec) + default: + return + } + } +} + +func (s *ArchiveService) writeRecord(rec *ArchiveRecord) { + s.writeCounter++ + // 周期性检查磁盘水位(每 256 条 + 切片时),避免每条 statfs 的 syscall 开销。 + if s.minFreeBytes > 0 && (s.writeCounter%256 == 1) { + s.diskFull = freeDiskBytes(s.dir) < s.minFreeBytes + } + if s.diskFull { + s.drop("disk") + return + } + + day := rec.Timestamp.Format("2006/01/02") + if s.enc == nil || day != s.curDay { + s.rotate(day) + } + if s.enc == nil { + s.drop("no_writer") + return + } + + line, err := json.Marshal(rec) + if err != nil { + s.drop("marshal") + return + } + line = append(line, '\n') + if _, err := s.enc.Write(line); err != nil { + logger.L().With(zap.String("component", "service.archive")). + Warn("archive.write_failed", zap.Error(err)) + s.closeShard() + return + } + s.curUncompressed += int64(len(line)) + s.written.Add(1) +} + +// rotate 关闭当前分片并按给定日期打开新分片。 +func (s *ArchiveService) rotate(day string) { + s.closeShard() + if s.minFreeBytes > 0 && freeDiskBytes(s.dir) < s.minFreeBytes { + s.diskFull = true + return + } + s.diskFull = false + + dayDir := filepath.Join(s.dir, day) + if err := os.MkdirAll(dayDir, 0o700); err != nil { + logger.L().With(zap.String("component", "service.archive")). + Error("archive.mkdir_day_failed", zap.String("dir", dayDir), zap.Error(err)) + return + } + + compact := strings.ReplaceAll(day, "/", "") + seq := nextShardSeq(dayDir, compact) + name := fmt.Sprintf("reqlog-%s-%03d.jsonl.zst", compact, seq) + path := filepath.Join(dayDir, name) + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + logger.L().With(zap.String("component", "service.archive")). + Error("archive.open_shard_failed", zap.String("path", path), zap.Error(err)) + return + } + enc, err := zstd.NewWriter(f, zstd.WithEncoderLevel(s.encoderLevel)) + if err != nil { + _ = f.Close() + logger.L().With(zap.String("component", "service.archive")). + Error("archive.new_encoder_failed", zap.Error(err)) + return + } + s.file = f + s.enc = enc + s.curDay = day + s.curShardSeq = seq + s.curUncompressed = 0 +} + +func (s *ArchiveService) flush() { + if s.enc != nil { + if err := s.enc.Flush(); err != nil { + logger.L().With(zap.String("component", "service.archive")). + Warn("archive.flush_failed", zap.Error(err)) + } + } +} + +// maybeRotateBySize 在 flush 后检查已压缩文件大小,超阈值则切片。 +func (s *ArchiveService) maybeRotateBySize() { + if s.file == nil || s.maxShardBytes <= 0 { + return + } + info, err := s.file.Stat() + if err != nil { + return + } + if info.Size() >= s.maxShardBytes { + s.rotate(s.curDay) + } +} + +// closeShard 收尾当前分片:finalize zstd frame(保证 .zst 自包含可拷走)。 +func (s *ArchiveService) closeShard() { + if s.enc != nil { + _ = s.enc.Close() + s.enc = nil + } + if s.file != nil { + _ = s.file.Close() + s.file = nil + } +} + +// encoderLevel 将配置的 1-4 映射为 zstd 编码级别(3 为默认)。 +func encoderLevel(level int) zstd.EncoderLevel { + switch level { + case 1: + return zstd.SpeedFastest + case 2: + return zstd.SpeedDefault + case 4: + return zstd.SpeedBestCompression + default: + return zstd.SpeedBetterCompression // 3:默认 + } +} + +// ---- header 白名单(全部小写匹配)---- + +var archiveReqHeaderExact = map[string]struct{}{ + "anthropic-version": {}, + "anthropic-beta": {}, + "openai-beta": {}, + "content-type": {}, + "idempotency-key": {}, + "version": {}, + "x-app": {}, + "originator": {}, + "user-agent": {}, + "accept-language": {}, +} + +var archiveReqHeaderPrefix = []string{"x-stainless-"} + +var archiveRespHeaderExact = map[string]struct{}{ + "request-id": {}, + "x-request-id": {}, + "anthropic-request-id": {}, + "x-amzn-requestid": {}, + "x-goog-request-id": {}, + "retry-after": {}, + "cf-ray": {}, + "cf-mitigated": {}, +} + +var archiveRespHeaderPrefix = []string{"x-codex-", "anthropic-ratelimit-unified-"} + +func filterArchiveHeaders(h http.Header, exact map[string]struct{}, prefixes []string) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string) + for k, v := range h { + lk := strings.ToLower(k) + if !archiveHeaderAllowed(lk, exact, prefixes) { + continue + } + if len(v) > 0 { + out[lk] = v[0] + } + } + if len(out) == 0 { + return nil + } + return out +} + +func archiveHeaderAllowed(lk string, exact map[string]struct{}, prefixes []string) bool { + if _, ok := exact[lk]; ok { + return true + } + for _, p := range prefixes { + if strings.HasPrefix(lk, p) { + return true + } + } + return false +} + +// hashClientIP 返回 SHA256(salt|ip) 的十六进制;ip 为空时返回空串。 +func hashClientIP(ip, salt string) string { + ip = strings.TrimSpace(ip) + if ip == "" { + return "" + } + sum := sha256.Sum256([]byte(salt + "|" + ip)) + return hex.EncodeToString(sum[:]) +} + +// ---- 辅助 ---- + +func archiveMaxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// intOrDefault 在 v<=0 时回退到 def,否则用 v。 +func intOrDefault(v, def int) int { + if v <= 0 { + return def + } + return v +} + +// resolveArchiveDataDir 解析默认数据目录(与日志/定价目录一致的约定)。 +func resolveArchiveDataDir() string { + if d := strings.TrimSpace(os.Getenv("DATA_DIR")); d != "" { + return d + } + if _, err := os.Stat("/app/data"); err == nil { + return "/app/data" + } + return "data" +} + +// freeDiskBytes 返回 path 所在分区的可用字节数;失败返回最大值(视为充足)。 +func freeDiskBytes(path string) uint64 { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return ^uint64(0) + } + return uint64(st.Bavail) * uint64(st.Bsize) +} + +// nextShardSeq 扫描当天目录,返回下一个分片序号。 +func nextShardSeq(dayDir, compactDay string) int { + matches, _ := filepath.Glob(filepath.Join(dayDir, fmt.Sprintf("reqlog-%s-*.jsonl.zst", compactDay))) + return len(matches) +} + +// loadOrCreateIPSalt 在归档目录维护一份持久盐文件,保证跨重启 IP 哈希稳定。 +func loadOrCreateIPSalt(dir string) string { + path := filepath.Join(dir, ".ip_salt") + if b, err := os.ReadFile(path); err == nil { + if s := strings.TrimSpace(string(b)); s != "" { + return s + } + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + // 退化:用时间不可用(会破坏稳定性),改用固定占位并告警。 + logger.L().With(zap.String("component", "service.archive")). + Warn("archive.ip_salt_rand_failed_using_placeholder", zap.Error(err)) + return "sub2api-archive-default-salt" + } + salt := hex.EncodeToString(buf) + if err := os.WriteFile(path, []byte(salt), 0o600); err != nil { + logger.L().With(zap.String("component", "service.archive")). + Warn("archive.ip_salt_persist_failed", zap.Error(err)) + } + return salt +} diff --git a/backend/internal/service/archive_service_test.go b/backend/internal/service/archive_service_test.go new file mode 100644 index 00000000000..f9a87a411e0 --- /dev/null +++ b/backend/internal/service/archive_service_test.go @@ -0,0 +1,304 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/klauspost/compress/zstd" +) + +func newTestArchiveConfig(dir string) *config.Config { + cfg := &config.Config{} + cfg.Archive = config.ArchiveConfig{ + Enabled: true, + Dir: dir, + MaxShardSizeMB: 512, + QueueMaxItems: 256, + QueueMaxBytes: 64 * 1024 * 1024, + MaxResponseBytes: 1 << 20, + CompressionLevel: 3, + FlushIntervalMs: 50, + MinFreeDiskGB: 0, + } + return cfg +} + +// findShard 在归档目录下找到唯一的 .jsonl.zst 分片文件。 +func findShard(t *testing.T, dir string) string { + t.Helper() + var found []string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && info != nil && !info.IsDir() && strings.HasSuffix(path, ".jsonl.zst") { + found = append(found, path) + } + return nil + }) + if len(found) != 1 { + t.Fatalf("expected exactly 1 shard file, found %d: %v", len(found), found) + } + return found[0] +} + +// readShardRecords 解压分片并把每行解析为 map。 +func readShardRecords(t *testing.T, path string) []map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shard: %v", err) + } + dec, err := zstd.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("zstd reader: %v", err) + } + defer dec.Close() + + var records []map[string]any + sc := bufio.NewScanner(dec) + sc.Buffer(make([]byte, 0, 1<<20), 16<<20) + for sc.Scan() { + line := sc.Bytes() + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var m map[string]any + if err := json.Unmarshal(line, &m); err != nil { + t.Fatalf("unmarshal record: %v (line=%s)", err, line) + } + records = append(records, m) + } + if err := sc.Err(); err != nil { + t.Fatalf("scan: %v", err) + } + return records +} + +func TestArchiveServiceRoundTrip(t *testing.T) { + dir := t.TempDir() + svc := NewArchiveService(newTestArchiveConfig(dir)) + if !svc.Enabled() { + t.Fatal("service should be enabled") + } + + svc.Capture(ArchiveInput{ + Body: []byte(`{"model":"claude-opus-4-8","messages":[]}`), + RespBody: []byte("data: hello\n\n"), + ReqHeaders: http.Header{ + "Authorization": {"Bearer sk-secret"}, + "X-Api-Key": {"sk-secret-2"}, + "Anthropic-Version": {"2023-06-01"}, + "User-Agent": {"claude-cli/1.2"}, + "X-Stainless-Os": {"MacOS"}, + "Accept-Language": {"zh-CN"}, + }, + RespHeaders: http.Header{ + "Cf-Ray": {"abc123"}, + "Set-Cookie": {"session=secret"}, + "X-Request-Id": {"req_xyz"}, + }, + UserID: 7, + APIKeyID: 42, + AccountID: 99, + Model: "claude-opus-4-8", + InboundEndpoint: "/v1/messages", + Stream: true, + Status: 200, + ClientIP: "1.2.3.4", + Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 2, CacheReadInputTokens: 5}, + }) + svc.Stop() + + records := readShardRecords(t, findShard(t, dir)) + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + rec := records[0] + + // 请求体/响应体完整。 + if _, ok := rec["request_body"]; !ok { + t.Error("request_body missing") + } + if got := rec["response_body"]; got != "data: hello\n\n" { + t.Errorf("response_body = %q", got) + } + + // IP 仅哈希,绝不出现原文。 + ipHash, _ := rec["ip_hash"].(string) + if ipHash == "" || ipHash == "1.2.3.4" { + t.Errorf("ip_hash invalid: %q", ipHash) + } + if strings.Contains(string(mustJSON(t, rec)), "1.2.3.4") { + t.Error("raw client IP leaked into record") + } + + // 请求 header 白名单:保留 anthropic-version/user-agent/x-stainless-*/accept-language,剥离密钥。 + reqH, _ := rec["request_headers"].(map[string]any) + for _, want := range []string{"anthropic-version", "user-agent", "x-stainless-os", "accept-language"} { + if _, ok := reqH[want]; !ok { + t.Errorf("request header %q should be kept", want) + } + } + for _, deny := range []string{"authorization", "x-api-key"} { + if _, ok := reqH[deny]; ok { + t.Errorf("secret header %q must be stripped", deny) + } + } + + // 响应 header 白名单:保留 cf-ray/x-request-id,剥离 set-cookie。 + respH, _ := rec["response_headers"].(map[string]any) + for _, want := range []string{"cf-ray", "x-request-id"} { + if _, ok := respH[want]; !ok { + t.Errorf("response header %q should be kept", want) + } + } + if _, ok := respH["set-cookie"]; ok { + t.Error("set-cookie must be stripped") + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func TestArchiveServiceDisabled(t *testing.T) { + cfg := &config.Config{} + cfg.Archive.Enabled = false + svc := NewArchiveService(cfg) + if svc.Enabled() { + t.Fatal("disabled service must report Enabled()==false") + } + // 不应 panic,且为 no-op。 + svc.Capture(ArchiveInput{Body: []byte(`{}`)}) + svc.Stop() +} + +func TestArchiveServiceResponseTruncation(t *testing.T) { + dir := t.TempDir() + cfg := newTestArchiveConfig(dir) + cfg.Archive.MaxResponseBytes = 8 + svc := NewArchiveService(cfg) + + svc.Capture(ArchiveInput{ + Body: []byte(`{"a":1}`), + RespBody: []byte("0123456789ABCDEF"), + Status: 200, + }) + svc.Stop() + + rec := readShardRecords(t, findShard(t, dir))[0] + if got := rec["response_body"]; got != "01234567" { + t.Errorf("truncated response_body = %q, want 01234567", got) + } + if trunc, _ := rec["response_truncated"].(bool); !trunc { + t.Error("response_truncated should be true") + } +} + +func TestArchiveServiceDiskFullStopsWriting(t *testing.T) { + dir := t.TempDir() + cfg := newTestArchiveConfig(dir) + // 设一个不可能满足的剩余空间阈值,触发停写。 + cfg.Archive.MinFreeDiskGB = 1 << 20 // 1 PB + svc := NewArchiveService(cfg) + + svc.Capture(ArchiveInput{Body: []byte(`{"a":1}`), Status: 200}) + svc.Stop() + + var shards []string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && info != nil && strings.HasSuffix(path, ".jsonl.zst") { + shards = append(shards, path) + } + return nil + }) + if len(shards) != 0 { + t.Errorf("disk-full guard should prevent shard creation, found: %v", shards) + } +} + +func TestFilterArchiveHeaders(t *testing.T) { + h := http.Header{ + "Authorization": {"secret"}, + "Anthropic-Beta": {"x"}, + "X-Stainless-Lang": {"go"}, + "Anthropic-Ratelimit-Unified-Reset": {"5"}, + "Cookie": {"y"}, + } + req := filterArchiveHeaders(h, archiveReqHeaderExact, archiveReqHeaderPrefix) + if _, ok := req["authorization"]; ok { + t.Error("authorization must be stripped") + } + if _, ok := req["anthropic-beta"]; !ok { + t.Error("anthropic-beta should be kept") + } + if _, ok := req["x-stainless-lang"]; !ok { + t.Error("x-stainless-lang should be kept") + } + + resp := filterArchiveHeaders(h, archiveRespHeaderExact, archiveRespHeaderPrefix) + if _, ok := resp["anthropic-ratelimit-unified-reset"]; !ok { + t.Error("anthropic-ratelimit-unified-* should be kept on response") + } + if _, ok := resp["cookie"]; ok { + t.Error("cookie must be stripped") + } +} + +func TestHashClientIP(t *testing.T) { + if hashClientIP("", "salt") != "" { + t.Error("empty IP must hash to empty string") + } + a := hashClientIP("1.2.3.4", "salt") + b := hashClientIP("1.2.3.4", "salt") + if a == "" || a != b { + t.Errorf("hash must be deterministic and non-empty: %q vs %q", a, b) + } + if a == hashClientIP("1.2.3.4", "other-salt") { + t.Error("different salt must yield different hash") + } + if strings.Contains(a, "1.2.3.4") { + t.Error("hash must not contain raw IP") + } +} + +func TestArchiveServiceRequestBodyNonJSONFallback(t *testing.T) { + dir := t.TempDir() + svc := NewArchiveService(newTestArchiveConfig(dir)) + svc.Capture(ArchiveInput{ + Body: []byte("not-json-at-all"), + Status: 200, + }) + svc.Stop() + + rec := readShardRecords(t, findShard(t, dir))[0] + // 非法 JSON 被作为 JSON 字符串存储,记录整体仍可解析。 + if got, _ := rec["request_body"].(string); got != "not-json-at-all" { + t.Errorf("non-json request body = %v", got) + } +} + +// 确保 flush ticker 与停机收尾不会死锁(粗略时序保护)。 +func TestArchiveServiceStopIsPrompt(t *testing.T) { + dir := t.TempDir() + svc := NewArchiveService(newTestArchiveConfig(dir)) + svc.Capture(ArchiveInput{Body: []byte(`{"a":1}`), Status: 200}) + done := make(chan struct{}) + go func() { svc.Stop(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Stop did not return promptly") + } +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 908783eb889..2b60287f668 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -607,6 +607,7 @@ var ProviderSet = wire.NewSet( ProvideConcurrencyService, ProvideUserMessageQueueService, NewUsageRecordWorkerPool, + NewArchiveService, ProvideSchedulerSnapshotService, NewIdentityService, NewCRSSyncService, diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 22713c59aab..8e73469e0b3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,94 +1,100 @@ -# ============================================================================= -# Sub2API Docker Compose Configuration -# ============================================================================= -# Quick Start: -# 1. Copy .env.example to .env and configure -# 2. docker-compose up -d -# 3. Check logs: docker-compose logs -f sub2api -# 4. Access: http://localhost:8080 -# -# All configuration is done via environment variables. -# No Setup Wizard needed - the system auto-initializes on first run. -# ============================================================================= + # ============================================================================= + # Sub2API Docker Compose - Local Directory Version + # ============================================================================= + # This configuration uses local directories for data storage instead of named + # volumes, making it easy to migrate the entire deployment by simply copying + # the deploy directory. + # + # Quick Start: + # 1. Copy .env.example to .env and configure + # 2. mkdir -p data postgres_data redis_data + # 3. docker-compose -f docker-compose.local.yml up -d + # 4. Check logs: docker-compose -f docker-compose.local.yml logs -f sub2api + # 5. Access: http://localhost:8080 + # + # Migration to New Server: + # 1. docker-compose -f docker-compose.local.yml down + # 2. tar czf sub2api-deploy.tar.gz deploy/ + # 3. Transfer to new server and extract + # 4. docker-compose -f docker-compose.local.yml up -d + # ============================================================================= -services: - # =========================================================================== - # Sub2API Application - # =========================================================================== - sub2api: - image: weishaw/sub2api:latest - container_name: sub2api - restart: unless-stopped - ulimits: - nofile: - soft: 100000 - hard: 100000 - ports: - - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080" - volumes: - # Data persistence (config.yaml will be auto-generated here) - - sub2api_data:/app/data - # Optional: Mount custom config.yaml (uncomment and create the file first) - # Copy config.example.yaml to config.yaml, modify it, then uncomment: - # - ./config.yaml:/app/data/config.yaml - # Optional: Mount a custom Codex instructions template file, then point - # gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl - # in config.yaml. - # - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro - environment: - # ======================================================================= - # Auto Setup (REQUIRED for Docker deployment) - # ======================================================================= - - AUTO_SETUP=true + services: + # =========================================================================== + # Sub2API Application + # =========================================================================== + sub2api: + image: doctor11ma/sub2api:v0.1.138feat + container_name: sub2api + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + ports: + - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + # Local directory mapping for easy migration + - ./data:/app/data + # Optional: Mount custom config.yaml (uncomment and create the file first) + # Copy config.example.yaml to config.yaml, modify it, then uncomment: + # - ./config.yaml:/app/data/config.yaml + environment: + # ======================================================================= + # Auto Setup (REQUIRED for Docker deployment) + # ======================================================================= + - AUTO_SETUP=true - # ======================================================================= - # Server Configuration - # ======================================================================= - - SERVER_HOST=0.0.0.0 - - SERVER_PORT=8080 - - SERVER_MODE=${SERVER_MODE:-release} - - RUN_MODE=${RUN_MODE:-standard} + # ======================================================================= + # Server Configuration + # ======================================================================= + - SERVER_HOST=0.0.0.0 + - SERVER_PORT=8080 + - SERVER_MODE=${SERVER_MODE:-release} + - RUN_MODE=${RUN_MODE:-standard} - # ======================================================================= - # Database Configuration (PostgreSQL) - # ======================================================================= - - DATABASE_HOST=postgres - - DATABASE_PORT=5432 - - DATABASE_USER=${POSTGRES_USER:-sub2api} - - DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - - DATABASE_DBNAME=${POSTGRES_DB:-sub2api} - - DATABASE_SSLMODE=disable - - DATABASE_MAX_OPEN_CONNS=${DATABASE_MAX_OPEN_CONNS:-50} - - DATABASE_MAX_IDLE_CONNS=${DATABASE_MAX_IDLE_CONNS:-10} - - DATABASE_CONN_MAX_LIFETIME_MINUTES=${DATABASE_CONN_MAX_LIFETIME_MINUTES:-30} - - DATABASE_CONN_MAX_IDLE_TIME_MINUTES=${DATABASE_CONN_MAX_IDLE_TIME_MINUTES:-5} + # ======================================================================= + # Database Configuration (PostgreSQL) + # ======================================================================= + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_USER=${POSTGRES_USER:-sub2api} + - DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - DATABASE_DBNAME=${POSTGRES_DB:-sub2api} + - DATABASE_SSLMODE=disable + - DATABASE_MAX_OPEN_CONNS=${DATABASE_MAX_OPEN_CONNS:-50} + - DATABASE_MAX_IDLE_CONNS=${DATABASE_MAX_IDLE_CONNS:-10} + - DATABASE_CONN_MAX_LIFETIME_MINUTES=${DATABASE_CONN_MAX_LIFETIME_MINUTES:-30} + - DATABASE_CONN_MAX_IDLE_TIME_MINUTES=${DATABASE_CONN_MAX_IDLE_TIME_MINUTES:-5} - # ======================================================================= - # Redis Configuration - # ======================================================================= - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD:-} - - REDIS_DB=${REDIS_DB:-0} - - REDIS_POOL_SIZE=${REDIS_POOL_SIZE:-1024} - - REDIS_MIN_IDLE_CONNS=${REDIS_MIN_IDLE_CONNS:-10} - - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false} + # ======================================================================= + # Redis Configuration + # ======================================================================= + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + - REDIS_POOL_SIZE=${REDIS_POOL_SIZE:-1024} + - REDIS_MIN_IDLE_CONNS=${REDIS_MIN_IDLE_CONNS:-10} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false} - # ======================================================================= - # Admin Account (auto-created on first run) - # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + # ======================================================================= + # Admin Account (auto-created on first run) + # ======================================================================= + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - # ======================================================================= - # JWT Configuration - # ======================================================================= - # IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being - # invalidated after container restarts. If left empty, a random secret - # will be generated on each startup. - # Generate a secure secret: openssl rand -hex 32 - - JWT_SECRET=${JWT_SECRET:-} - - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + # ======================================================================= + # JWT Configuration + # ======================================================================= + # IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being + # invalidated after container restarts. If left empty, a random secret + # will be generated on each startup. + # Generate a secure secret: openssl rand -hex 32 + - JWT_SECRET=${JWT_SECRET:-} + - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} # ======================================================================= # Setup Configuration @@ -105,30 +111,30 @@ services: # Generate a secure key: openssl rand -hex 32 - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} - # ======================================================================= - # Timezone Configuration - # This affects ALL time operations in the application: - # - Database timestamps - # - Usage statistics "today" boundary - # - Subscription expiry times - # - Log timestamps - # Common values: Asia/Shanghai, America/New_York, Europe/London, UTC - # ======================================================================= - - TZ=${TZ:-Asia/Shanghai} + # ======================================================================= + # Timezone Configuration + # This affects ALL time operations in the application: + # - Database timestamps + # - Usage statistics "today" boundary + # - Subscription expiry times + # - Log timestamps + # Common values: Asia/Shanghai, America/New_York, Europe/London, UTC + # ======================================================================= + - TZ=${TZ:-Asia/Shanghai} - # ======================================================================= - # Gemini OAuth Configuration (for Gemini accounts) - # ======================================================================= - - GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-} - - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} - - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + # ======================================================================= + # Gemini OAuth Configuration (for Gemini accounts) + # ======================================================================= + - GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-} + - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} + - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} + - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - # Built-in OAuth client secrets (optional) - # SECURITY: This repo does not embed third-party client_secret. - - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} - - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} - - ANTIGRAVITY_USER_AGENT_VERSION=${ANTIGRAVITY_USER_AGENT_VERSION:-} + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_USER_AGENT_VERSION=${ANTIGRAVITY_USER_AGENT_VERSION:-} # ======================================================================= # Security Configuration (URL Allowlist) @@ -142,139 +148,115 @@ services: # Upstream hosts whitelist (comma-separated, only used when enabled=true) - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} - # ======================================================================= - # Update Configuration (在线更新配置) - # ======================================================================= - # Proxy for accessing GitHub (online updates + pricing data) - # Examples: http://host:port, socks5://host:port - - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} + # ======================================================================= + # Update Configuration (在线更新配置) + # ======================================================================= + # Proxy for accessing GitHub (online updates + pricing data) + # Examples: http://host:port, socks5://host:port + - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} - # ======================================================================= - # Image Generation Stream & Concurrency - # ======================================================================= - # OpenAI HTTP upstream protocol/timeout - - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} - - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} - - GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS=${GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS:-60} - - GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS=${GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS:-600} - - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} - - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} - - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} - - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - sub2api-network - healthcheck: - test: - [ - "CMD", - "wget", - "-q", - "-T", - "5", - "-O", - "/dev/null", - "http://localhost:8080/health", - ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s + # ======================================================================= + # Image Generation Stream & Concurrency + # ======================================================================= + - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} + - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} + - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} + - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} + - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} + - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} + # ======================================================================= + # 请求/响应归档(落盘到 /app/data/archive,即宿主机 ./data/archive) + # ======================================================================= + - ARCHIVE_ENABLED=${ARCHIVE_ENABLED:-true} + # 以下都有合理默认,按需覆盖即可: + - ARCHIVE_MAX_SHARD_SIZE_MB=${ARCHIVE_MAX_SHARD_SIZE_MB:-512} + - ARCHIVE_QUEUE_MAX_BYTES=${ARCHIVE_QUEUE_MAX_BYTES:-268435456} + - ARCHIVE_MAX_RESPONSE_BYTES=${ARCHIVE_MAX_RESPONSE_BYTES:-16777216} + - ARCHIVE_COMPRESSION_LEVEL=${ARCHIVE_COMPRESSION_LEVEL:-3} + - ARCHIVE_MIN_FREE_DISK_GB=${ARCHIVE_MIN_FREE_DISK_GB:-10} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - sub2api-network + healthcheck: + test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s - # =========================================================================== - # PostgreSQL Database - # =========================================================================== - postgres: - image: postgres:18-alpine - container_name: sub2api-postgres - restart: unless-stopped - ulimits: - nofile: - soft: 100000 - hard: 100000 - volumes: - - postgres_data:/var/lib/postgresql/data - environment: - # postgres:18-alpine 默认 PGDATA=/var/lib/postgresql/18/docker(位于镜像声明的匿名卷 /var/lib/postgresql 内)。 - # 若不显式设置 PGDATA,则即使挂载了 postgres_data 到 /var/lib/postgresql/data,数据也不会落盘到该命名卷, - # docker compose down/up 后会触发 initdb 重新初始化,导致用户/密码等数据丢失。 - - PGDATA=/var/lib/postgresql/data - - POSTGRES_USER=${POSTGRES_USER:-sub2api} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - - POSTGRES_DB=${POSTGRES_DB:-sub2api} - - TZ=${TZ:-Asia/Shanghai} - networks: - - sub2api-network - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}", - ] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - # 注意:不暴露端口到宿主机,应用通过内部网络连接 - # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] + # =========================================================================== + # PostgreSQL Database + # =========================================================================== + postgres: + image: postgres:18-alpine + container_name: sub2api-postgres + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + volumes: + # Local directory mapping for easy migration + - ./postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${POSTGRES_USER:-sub2api} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - POSTGRES_DB=${POSTGRES_DB:-sub2api} + - PGDATA=/var/lib/postgresql/data + - TZ=${TZ:-Asia/Shanghai} + networks: + - sub2api-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + # 注意:不暴露端口到宿主机,应用通过内部网络连接 + # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] - # =========================================================================== - # Redis Cache - # =========================================================================== - redis: - image: redis:8-alpine - container_name: sub2api-redis - restart: unless-stopped - ulimits: - nofile: - soft: 100000 - hard: 100000 - volumes: - - redis_data:/data - command: > - sh -c ' - redis-server - --save 60 1 - --appendonly yes - --appendfsync everysec - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' - environment: - - TZ=${TZ:-Asia/Shanghai} - # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) - - REDISCLI_AUTH=${REDIS_PASSWORD:-} - networks: - - sub2api-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - # 注意:不暴露端口到宿主机,应用通过内部网络连接 - # 如需调试,可临时添加:ports: ["127.0.0.1:6379:6379"] -# ============================================================================= -# Volumes -# ============================================================================= -volumes: - sub2api_data: - driver: local - postgres_data: - driver: local - redis_data: - driver: local + # =========================================================================== + # Redis Cache + # =========================================================================== + redis: + image: redis:8-alpine + container_name: sub2api-redis + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + volumes: + # Local directory mapping for easy migration + - ./redis_data:/data + command: > + sh -c ' + redis-server + --save 60 1 + --appendonly yes + --appendfsync everysec + ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + environment: + - TZ=${TZ:-Asia/Shanghai} + # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) + - REDISCLI_AUTH=${REDIS_PASSWORD:-} + networks: + - sub2api-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s -# ============================================================================= -# Networks -# ============================================================================= -networks: - sub2api-network: - driver: bridge + # ============================================================================= + # Networks + # ============================================================================= + networks: + sub2api-network: + driver: bridge \ No newline at end of file diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3a9fb5ad739..cf034ed727a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -752,6 +752,7 @@ export default { created: 'Created', copyToClipboard: 'Copy to clipboard', copied: 'Copied!', + configScript: 'Config Script', importToCcSwitch: 'Import to CCS', enable: 'Enable', disable: 'Disable', @@ -783,6 +784,16 @@ export default { quota: 'Quota', lastUsedAt: 'Last Used', useKey: 'Use Key', + configScriptMenu: { + codexCli: 'Codex CLI', + claudeCode: 'Claude Code', + opencode: 'OpenCode', + hintMac: 'macOS downloads .sh. Run sh ~/Downloads/script-name.sh in terminal, or chmod +x before running.', + hintWindows: 'Windows downloads .bat. Double-click it or run it in CMD after downloading.', + unsupportedClient: 'This group does not support the selected client config script', + downloadStarted: 'Config script download started', + downloadFailed: 'Failed to download config script' + }, useKeyModal: { title: 'Use API Key', description: diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fdeaa868369..52fd3daf045 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -751,6 +751,7 @@ export default { created: '创建时间', copyToClipboard: '复制到剪贴板', copied: '已复制!', + configScript: '配置脚本', importToCcSwitch: '导入到 CCS', enable: '启用', disable: '禁用', @@ -782,6 +783,16 @@ export default { quota: '额度', lastUsedAt: '上次使用时间', useKey: '使用密钥', + configScriptMenu: { + codexCli: 'Codex CLI', + claudeCode: 'Claude Code', + opencode: 'OpenCode', + hintMac: 'macOS 下载 .sh;建议在终端执行 sh ~/Downloads/脚本名.sh,或 chmod +x 后运行。', + hintWindows: 'Windows 下载 .bat;下载后双击或在 CMD 中运行。', + unsupportedClient: '当前分组不支持此客户端配置脚本', + downloadStarted: '配置脚本已开始下载', + downloadFailed: '下载配置脚本失败' + }, useKeyModal: { title: '使用 API 密钥', description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行。', diff --git a/frontend/src/utils/__tests__/configScriptDownload.spec.ts b/frontend/src/utils/__tests__/configScriptDownload.spec.ts new file mode 100644 index 00000000000..eadbfc242b6 --- /dev/null +++ b/frontend/src/utils/__tests__/configScriptDownload.spec.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' +import { + buildAPIKeyConfigScript, + isConfigScriptClientAvailable +} from '../configScriptDownload' + +function decodeBatchPayload(content: string): string { + const marker = content.match(/(__LOOK2EYE_[A-Z_]+_PS1__)/)?.[1] + if (!marker) throw new Error('missing embedded payload marker') + const markerIndex = content.lastIndexOf(marker) + if (markerIndex < 0) throw new Error('missing embedded payload') + return content.slice(markerIndex + marker.length).trimStart() +} + +describe('configScriptDownload', () => { + it('builds a macOS Codex shell script with look2eye config values', () => { + const script = buildAPIKeyConfigScript({ + client: 'codex', + os: 'mac', + platform: 'openai', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-test-key' + }) + + expect(script.filename).toBe('look2eye-codex-config.sh') + expect(script.content).toContain('#!/usr/bin/env sh') + expect(script.content).toContain('look2eye Codex CLI 一键配置') + expect(script.content).toContain("BASE_URL='https://api.example.com/v1'") + expect(script.content).toContain("API_KEY='sk-test-key'") + expect(script.content).toContain('CONFIG_PATH="$CODEX_DIR/config.toml"') + expect(script.content).toContain('AUTH_PATH="$CODEX_DIR/auth.json"') + expect(script.content).toContain('BACKUP_DIR="$CODEX_DIR/backups"') + expect(script.content).toContain('restore|--restore|/restore') + expect(script.content).toContain('base_url = "{{LOOK2EYE_BASE_URL}}"') + expect(script.content).toContain('json.dumps({"OPENAI_API_KEY": api_key}') + expect(script.content).toContain('stop_codex_processes') + expect(script.content).toContain('ensure_codex_client') + expect(script.content).toContain('npm install -g @openai/codex') + }) + + it('builds a Windows Codex batch script with embedded setup payload', () => { + const script = buildAPIKeyConfigScript({ + client: 'codex', + os: 'win', + platform: 'openai', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-win-key', + siteName: 'Look2eye' + }) + + expect(script.filename).toBe('Look2eye-codex-config.bat') + expect(script.content).toContain('@echo off') + expect(script.content).toContain('setlocal EnableDelayedExpansion') + expect(script.content).toContain('LOOK2EYE_SETUP_EXIT') + expect(script.content).toContain('LOOK2EYE_SETUP_MARKER=__LOOK2EYE_CODEX_PS1__') + expect(script.content).toContain('-EncodedCommand ') + expect(script.content).toContain('__LOOK2EYE_CODEX_PS1__') + expect(script.content).not.toContain('$path =') + expect(script.content).toContain('Windows 未检测到 Node.js') + expect(script.content).toContain('是否现在通过 winget 安装 Node.js LTS?[Y/N]') + expect(script.content).toContain('if /i "!LOOK2EYE_INSTALL_NODE!"=="Y"') + expect(script.content).not.toContain('if /i "%LOOK2EYE_INSTALL_NODE%"=="Y"') + expect(script.content).toContain('where winget.exe') + expect(script.content).toContain('winget install --id OpenJS.NodeJS.LTS --source winget') + expect(script.content).toContain('npm install -g @openai/codex') + expect(script.content).toContain('where codex.cmd') + expect(script.content).toContain('https://nodejs.org/') + expect(script.content).toContain('where npm.cmd') + + const payload = decodeBatchPayload(script.content) + expect(payload).not.toContain('\uFEFF') + expect(payload).toContain("$BaseUrl = 'https://api.example.com/v1'") + expect(payload).toContain("$ApiKey = 'sk-win-key'") + expect(payload).toContain('$ConfigTomlTemplate') + expect(payload).toContain('model_provider = "Look2eye"') + expect(payload).toContain('Restore-CodexBackup') + expect(payload).toContain('Stop-CodexProcesses') + }) + + it('builds a macOS Claude Code shell script that merges settings.json', () => { + const script = buildAPIKeyConfigScript({ + client: 'claude', + os: 'mac', + platform: 'anthropic', + baseUrl: 'https://api.example.com/v1/', + apiKey: 'sk-claude-key', + siteName: 'Look2eye' + }) + + expect(script.filename).toBe('Look2eye-claude-code-config.sh') + expect(script.content).toContain('#!/usr/bin/env sh') + expect(script.content).toContain('Look2eye Claude Code 配置已完成') + expect(script.content).toContain("BASE_URL='https://api.example.com/v1'") + expect(script.content).toContain("API_KEY='sk-claude-key'") + expect(script.content).toContain('CLAUDE_DIR="$HOME/.claude"') + expect(script.content).toContain('SETTINGS_PATH="$CLAUDE_DIR/settings.json"') + expect(script.content).toContain('BACKUP_DIR="$CLAUDE_DIR/backups"') + expect(script.content).toContain('settings = json.loads') + expect(script.content).toContain('"ANTHROPIC_BASE_URL": base_url') + expect(script.content).toContain('"ANTHROPIC_AUTH_TOKEN": api_key') + expect(script.content).toContain('"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"') + expect(script.content).toContain('"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"') + expect(script.content).not.toContain('.look2eye/claude-code.env') + expect(script.content).toContain('ensure_claude_client') + expect(script.content).toContain('npm install -g @anthropic-ai/claude-code') + }) + + it('builds a Windows Claude Code batch script from the marker-based template', () => { + const script = buildAPIKeyConfigScript({ + client: 'claude', + os: 'win', + platform: 'antigravity', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-claude-key' + }) + + expect(script.filename).toBe('look2eye-claude-code-config.bat') + expect(script.content).toContain('@echo off') + expect(script.content).toContain('setlocal EnableDelayedExpansion') + expect(script.content).toContain('-EncodedCommand ') + expect(script.content).toContain('LOOK2EYE_SETUP_MARKER=__LOOK2EYE_CLAUDE_CODE_PS1__') + expect(script.content).toContain('__LOOK2EYE_CLAUDE_CODE_PS1__') + expect(script.content).not.toContain('$path =') + expect(script.content).toContain('Windows 未检测到 Node.js') + expect(script.content).toContain('是否现在通过 winget 安装 Node.js LTS?[Y/N]') + expect(script.content).toContain('if /i "!LOOK2EYE_INSTALL_NODE!"=="Y"') + expect(script.content).not.toContain('if /i "%LOOK2EYE_INSTALL_NODE%"=="Y"') + expect(script.content).toContain('where winget.exe') + expect(script.content).toContain('winget install --id OpenJS.NodeJS.LTS --source winget') + expect(script.content).toContain('npm install -g @anthropic-ai/claude-code') + expect(script.content).toContain('where claude.cmd') + expect(script.content).toContain('https://nodejs.org/') + expect(script.content).toContain('where npm.cmd') + + const payload = decodeBatchPayload(script.content) + expect(payload).not.toContain('\uFEFF') + expect(payload).toContain("Applying $SiteName Claude Code config") + expect(payload).toContain('https://api.example.com/v1') + expect(payload).toContain('sk-claude-key') + expect(payload).toContain('ConvertTo-OrderedMap') + expect(payload).toContain('Join-Path $targetHome ".claude"') + expect(payload).toContain('Join-Path $claudeDir "settings.json"') + expect(payload).toContain('Join-Path $claudeDir "backups"') + expect(payload).toContain('$envMap["ANTHROPIC_AUTH_TOKEN"] = $ApiKey') + expect(payload).toContain('$envMap["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "0"') + expect(payload).toContain('Write-Utf8NoBom') + }) + + it('builds a macOS OpenCode shell script that merges opencode.json', () => { + const script = buildAPIKeyConfigScript({ + client: 'opencode', + os: 'mac', + platform: 'openai', + baseUrl: 'https://api.example.com/v1/', + apiKey: 'sk-opencode-key', + siteName: 'Look2eye' + }) + + expect(script.filename).toBe('Look2eye-opencode-config.sh') + expect(script.content).toContain('#!/usr/bin/env sh') + expect(script.content).toContain('Look2eye OpenCode 配置已完成') + expect(script.content).toContain("BASE_URL='https://api.example.com/v1'") + expect(script.content).toContain("API_KEY='sk-opencode-key'") + expect(script.content).toContain('OPENCODE_DIR="$HOME/.config/opencode"') + expect(script.content).toContain('CONFIG_PATH="$OPENCODE_DIR/opencode.json"') + expect(script.content).toContain('BACKUP_DIR="$OPENCODE_DIR/backups"') + expect(script.content).toContain('default_models = json.loads') + expect(script.content).toContain('"gpt-5.5"') + expect(script.content).toContain('options["baseURL"] = base_url') + expect(script.content).toContain('options["apiKey"] = api_key') + expect(script.content).toContain('opts["store"] = False') + expect(script.content).not.toContain('Installing Look2eye OpenCode configuration') + expect(script.content).toContain('ensure_opencode_client') + expect(script.content).toContain('npm install -g opencode-ai') + }) + + it('builds a Windows OpenCode batch script from the marker-based template', () => { + const script = buildAPIKeyConfigScript({ + client: 'opencode', + os: 'win', + platform: 'gemini', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-opencode-win-key' + }) + + expect(script.filename).toBe('look2eye-opencode-config.bat') + expect(script.content).toContain('@echo off') + expect(script.content).toContain('setlocal EnableDelayedExpansion') + expect(script.content).toContain('-EncodedCommand ') + expect(script.content).toContain('LOOK2EYE_SETUP_MARKER=__LOOK2EYE_OPENCODE_PS1__') + expect(script.content).toContain('__LOOK2EYE_OPENCODE_PS1__') + expect(script.content).not.toContain('$path =') + expect(script.content).toContain('Windows 未检测到 Node.js') + expect(script.content).toContain('是否现在通过 winget 安装 Node.js LTS?[Y/N]') + expect(script.content).toContain('if /i "!LOOK2EYE_INSTALL_NODE!"=="Y"') + expect(script.content).not.toContain('if /i "%LOOK2EYE_INSTALL_NODE%"=="Y"') + expect(script.content).toContain('where winget.exe') + expect(script.content).toContain('winget install --id OpenJS.NodeJS.LTS --source winget') + expect(script.content).toContain('npm install -g opencode-ai') + expect(script.content).toContain('where opencode.cmd') + expect(script.content).toContain('https://nodejs.org/') + expect(script.content).toContain('where npm.cmd') + + const payload = decodeBatchPayload(script.content) + expect(payload).not.toContain('\uFEFF') + expect(payload).toContain("Applying $SiteName OpenCode config") + expect(payload).toContain("Join-Path (Join-Path $targetHome \".config\") \"opencode\"") + expect(payload).toContain('Join-Path $openCodeDir "opencode.json"') + expect(payload).toContain('Join-Path $openCodeDir "backups"') + expect(payload).toContain("$BaseUrl = 'https://api.example.com/v1'") + expect(payload).toContain("$ApiKey = 'sk-opencode-win-key'") + expect(payload).toContain('$ModelsJson') + expect(payload).toContain('"gpt-5.5"') + expect(payload).toContain('$options["baseURL"] = $BaseUrl.Trim().TrimEnd("/")') + expect(payload).toContain('$options["apiKey"] = $ApiKey') + expect(payload).toContain('Ensure-AgentStoreFalse') + expect(payload).toContain('Write-Utf8NoBom') + }) + + it('allows config scripts for every grouped platform', () => { + expect(isConfigScriptClientAvailable({ client: 'codex', platform: 'openai' })).toBe(true) + expect(isConfigScriptClientAvailable({ client: 'codex', platform: 'gemini' })).toBe(true) + expect(isConfigScriptClientAvailable({ client: 'claude', platform: 'openai' })).toBe(true) + expect(isConfigScriptClientAvailable({ client: 'claude', platform: 'openai', allowMessagesDispatch: true })).toBe(true) + expect(isConfigScriptClientAvailable({ client: 'opencode', platform: 'gemini' })).toBe(true) + }) +}) diff --git a/frontend/src/utils/configScriptDownload.ts b/frontend/src/utils/configScriptDownload.ts new file mode 100644 index 00000000000..039637495d6 --- /dev/null +++ b/frontend/src/utils/configScriptDownload.ts @@ -0,0 +1,1631 @@ +import type { GroupPlatform } from '@/types' +import { + WINDOWS_BASIC_BATCH_TEMPLATE, + WINDOWS_CLIENT_INSTALL_NOTICE_TEMPLATE, + WINDOWS_EMBEDDED_POWERSHELL_BATCH_TEMPLATE, + WINDOWS_NODE_RUNTIME_NOTICE_TEMPLATE, + renderConfigScriptTemplate +} from './configScriptTemplates' + +export const CONFIG_SCRIPT_SITE_NAME = 'look2eye' + +export type ConfigScriptClient = 'codex' | 'claude' | 'opencode' +export type ConfigScriptOS = 'mac' | 'win' + +export interface ConfigScriptInput { + client: ConfigScriptClient + os?: ConfigScriptOS + platform?: GroupPlatform | null + baseUrl: string + apiKey: string + siteName?: string + allowMessagesDispatch?: boolean +} + +interface ConfigFile { + path: string + content: string +} + +interface ConfigPayload { + label: string + files: ConfigFile[] + env?: Record +} + +interface ClientInstallSpec { + label: string + commandName: string + npmPackage: string +} + +const CODEX_INSTALL_SPEC: ClientInstallSpec = { + label: 'Codex CLI', + commandName: 'codex', + npmPackage: '@openai/codex' +} + +const CLAUDE_CODE_INSTALL_SPEC: ClientInstallSpec = { + label: 'Claude Code', + commandName: 'claude', + npmPackage: '@anthropic-ai/claude-code' +} + +const OPENCODE_INSTALL_SPEC: ClientInstallSpec = { + label: 'OpenCode', + commandName: 'opencode', + npmPackage: 'opencode-ai' +} + +const CODEX_DEFAULT_MODEL = 'gpt-5.5' +const CODEX_DEFAULT_REVIEW_MODEL = 'gpt-5.5' +const CODEX_REASONING_EFFORT = 'xhigh' +const CODEX_CONTEXT_WINDOW = 1000000 +const CODEX_AUTO_COMPACT_TOKEN_LIMIT = 900000 +const OPENCODE_DEFAULT_MODELS_JSON = `{ + "gpt-5.2": { + "name": "GPT-5.2", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "gpt-5.5": { + "name": "GPT-5.5", + "limit": { + "context": 1050000, + "output": 128000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "gpt-5.4": { + "name": "GPT-5.4", + "limit": { + "context": 1050000, + "output": 128000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "gpt-5.4-mini": { + "name": "GPT-5.4 Mini", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "gpt-5.3-codex-spark": { + "name": "GPT-5.3 Codex Spark", + "limit": { + "context": 128000, + "output": 32000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "gpt-5.3-codex": { + "name": "GPT-5.3 Codex", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + }, + "codex-mini-latest": { + "name": "Codex Mini", + "limit": { + "context": 200000, + "output": 100000 + }, + "options": { + "store": false + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + }, + "xhigh": { + "reasoningEffort": "xhigh" + } + } + } +}` + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') + +const stripV1Suffix = (value: string) => trimTrailingSlash(value).replace(/\/v1\/?$/, '') + +const ensureSuffix = (value: string, suffix: string) => { + const trimmed = trimTrailingSlash(value) + return trimmed.endsWith(suffix) ? trimmed : `${trimmed}${suffix}` +} + +const resolveBaseUrl = (baseUrl: string) => trimTrailingSlash(baseUrl || window.location.origin) + +const resolveAPIBase = (baseUrl: string) => ensureSuffix(stripV1Suffix(resolveBaseUrl(baseUrl)), '/v1') + +const resolveGeminiBase = (baseUrl: string) => ensureSuffix(stripV1Suffix(resolveBaseUrl(baseUrl)), '/v1beta') + +const resolveAntigravityBase = (baseUrl: string) => + ensureSuffix(`${stripV1Suffix(resolveBaseUrl(baseUrl))}/antigravity`, '/v1') + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function shellClientInstallNotice(spec: ClientInstallSpec): string { + return ` +ensure_${spec.commandName}_client() { + if command -v ${spec.commandName} >/dev/null 2>&1; then + ${spec.commandName} --version 2>/dev/null || true + return 0 + fi + + echo "[LOOK2EYE NOTICE] 未检测到 ${spec.label},准备通过 npm 全局安装。" + if ! command -v npm >/dev/null 2>&1; then + echo "未检测到 npm,无法自动安装 ${spec.label}。请先安装 Node.js LTS:https://nodejs.org/" >&2 + echo "安装 Node.js 后可手动执行:npm install -g ${spec.npmPackage}" >&2 + return 0 + fi + + if npm install -g ${spec.npmPackage}; then + if command -v ${spec.commandName} >/dev/null 2>&1; then + ${spec.commandName} --version 2>/dev/null || true + else + echo "${spec.label} 已安装,但当前终端仍未检测到 ${spec.commandName};请重新打开终端后再运行客户端。" >&2 + fi + else + echo "${spec.label} 自动安装失败,请手动执行:npm install -g ${spec.npmPackage}" >&2 + fi +} +` +} + +function powershellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function json(value: unknown): string { + return JSON.stringify(value, null, 2) +} + +function codexProviderName(siteName: string): string { + const sanitized = siteName.replace(/[^A-Za-z0-9_-]+/g, '') + return sanitized || 'Look2eye' +} + +function codexConfigTemplate(input: Required>): string { + const providerName = codexProviderName(input.siteName) + return `model_provider = "${providerName}" +model = "${CODEX_DEFAULT_MODEL}" +review_model = "${CODEX_DEFAULT_REVIEW_MODEL}" +model_reasoning_effort = "${CODEX_REASONING_EFFORT}" +model_context_window = ${CODEX_CONTEXT_WINDOW} +model_auto_compact_token_limit = ${CODEX_AUTO_COMPACT_TOKEN_LIMIT} +disable_response_storage = true +network_access = "enabled" +windows_wsl_setup_acknowledged = true + +[agents] +max_threads = 20 +max_depth = 1 + +[model_providers.${providerName}] +name = "${providerName}" +base_url = "{{LOOK2EYE_BASE_URL}}" +wire_api = "responses" +supports_websockets = {{LOOK2EYE_ENABLE_WEBSOCKET}} + +[features] +responses_websockets_v2 = {{LOOK2EYE_ENABLE_WEBSOCKET}} +goals = true + +[windows] +sandbox = "elevated" +` +} + +function buildCodexShellScript(input: Required>): string { + const siteName = input.siteName + const baseUrl = resolveBaseUrl(input.baseUrl) + const template = codexConfigTemplate(input) + const clientInstall = shellClientInstallNotice(CODEX_INSTALL_SPEC) + + return `#!/usr/bin/env sh +set -eu + +look2eye_setup_result() { + rc=$? + if [ "$rc" -eq 0 ]; then + printf '\\n' + echo "============================================================" + echo "[LOOK2EYE SETUP SUCCESS] ${siteName} Codex CLI 配置已完成。" + echo "请重新打开 Codex 或对应客户端,让新配置生效。" + echo "============================================================" + else + printf '\\n' >&2 + echo "============================================================" >&2 + echo "[LOOK2EYE SETUP FAILED] ${siteName} Codex CLI 配置未完成或未完全生效。退出码:$rc" >&2 + echo "请查看上方失败原因;如提示 Codex 未关闭,请手动关闭后重试。" >&2 + echo "============================================================" >&2 + fi + trap - EXIT + exit "$rc" +} +trap look2eye_setup_result EXIT + +BASE_URL=${shellQuote(baseUrl)} +API_KEY=${shellQuote(input.apiKey)} +ENABLE_WEBSOCKET='false' +CONFIG_TOML_TEMPLATE=${shellQuote(template)} +CODEX_DIR="$HOME/.codex" +CONFIG_PATH="$CODEX_DIR/config.toml" +AUTH_PATH="$CODEX_DIR/auth.json" +BACKUP_DIR="$CODEX_DIR/backups" + +mkdir -p "$CODEX_DIR" "$BACKUP_DIR" + +stop_codex_processes() { + if [ "\${LOOK2EYE_SKIP_CODEX_PROCESS_CLOSE:-}" = "1" ]; then + echo "已跳过结束 Codex 进程。" + return 0 + fi + + if ! command -v pkill >/dev/null 2>&1; then + echo "配置已写入,但未找到 pkill,无法自动结束 Codex 进程。请手动关闭 Codex 后重新打开。" >&2 + return 1 + fi + + stopped=0 + failed=0 + for process_name in codex Codex; do + count=0 + if command -v pgrep >/dev/null 2>&1; then + count=$(pgrep -x "$process_name" 2>/dev/null | wc -l | tr -d ' ') + fi + if pkill -x "$process_name" >/dev/null 2>&1; then + if [ "\${count:-0}" -gt 0 ]; then + stopped=$((stopped + count)) + else + stopped=$((stopped + 1)) + fi + elif [ "\${count:-0}" -gt 0 ]; then + failed=1 + fi + done + + if [ "$stopped" -gt 0 ]; then + echo "已结束 Codex 进程:$stopped 个。" + else + echo "未发现正在运行的 Codex 进程;重新打开 Codex 即可使用新配置。" + fi + if [ "$failed" -ne 0 ]; then + echo "配置已写入,但部分 Codex 进程无法自动关闭。请手动关闭 Codex 后重新打开。" >&2 + return 1 + fi + return 0 +} + +new_backup_stamp() { + base=$(date +"%Y%m%d-%H%M%S") + stamp="$base" + counter=2 + while [ -e "$BACKUP_DIR/$(basename "$CONFIG_PATH").bak-$stamp" ] || [ -e "$BACKUP_DIR/$(basename "$AUTH_PATH").bak-$stamp" ]; do + stamp="$base-$(printf "%02d" "$counter")" + counter=$((counter + 1)) + done + printf '%s\\n' "$stamp" +} + +PYTHON_BIN= +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python3 +elif command -v python >/dev/null 2>&1 && python -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python +else + echo "失败:需要可用的 python3 或 python 才能安全合并或恢复现有 Codex 配置。" >&2 + exit 1 +fi + +LOOK2EYE_ACTION=\${1:-apply} + +case "$LOOK2EYE_ACTION" in + ""|1|apply|--apply) + printf "\\n${siteName} Codex CLI 一键配置:默认覆盖当前配置。\\n" + echo "如需恢复备份,请在终端运行本脚本并追加 restore 参数。" + ;; + 2|restore|--restore|/restore) + "$PYTHON_BIN" - "$CONFIG_PATH" "$AUTH_PATH" "$BACKUP_DIR" <<'PY' +import shutil +import sys +from datetime import datetime +from pathlib import Path + +config_path = Path(sys.argv[1]) +auth_path = Path(sys.argv[2]) +backup_dir = Path(sys.argv[3]) + +def collect(path, kind, sets): + prefix = path.name + ".bak-" + for directory in (backup_dir, path.parent): + if not directory.exists(): + continue + for candidate in directory.glob(prefix + "*"): + if not candidate.is_file(): + continue + stamp = candidate.name[len(prefix):] + entry = sets.setdefault(stamp, {"timestamp": stamp, "config": None, "auth": None, "mtime": 0.0}) + candidate_mtime = candidate.stat().st_mtime + if entry[kind] is None or candidate_mtime > entry[kind].stat().st_mtime: + entry[kind] = candidate + entry["mtime"] = max(entry["mtime"], candidate_mtime) + +sets = {} +collect(config_path, "config", sets) +collect(auth_path, "auth", sets) +items = sorted(sets.values(), key=lambda item: item["mtime"], reverse=True) +if not items: + raise SystemExit("失败:未找到之前的 Codex config/auth 备份。") + +print("") +print("可用备份:") +for index, item in enumerate(items, 1): + labels = {"config": "配置", "auth": "认证"} + parts = "+".join(labels[kind] for kind in ("config", "auth") if item[kind] is not None) + file_time = datetime.fromtimestamp(item["mtime"]).strftime("%Y-%m-%d %H:%M:%S") + print(f"{index}) {item['timestamp']} [{parts}] 文件时间 {file_time}") + +choice = input("请选择备份序号:").strip() +try: + selected = items[int(choice) - 1] +except (ValueError, IndexError): + raise SystemExit("失败:备份序号无效。") + +if selected["config"] is not None: + shutil.copy2(selected["config"], config_path) + print(f"已恢复:{config_path}") +if selected["auth"] is not None: + shutil.copy2(selected["auth"], auth_path) + print(f"已恢复:{auth_path}") +print("已完成,之前的 Codex 配置已恢复。") +PY + stop_codex_processes + exit 0 + ;; + *) + echo "失败:参数无效:$LOOK2EYE_ACTION。直接运行会配置 ${siteName};恢复备份请使用 restore 参数。" >&2 + exit 1 + ;; +esac + +timestamp=$(new_backup_stamp) +if [ -f "$CONFIG_PATH" ]; then + config_backup="$BACKUP_DIR/$(basename "$CONFIG_PATH").bak-$timestamp" + cp "$CONFIG_PATH" "$config_backup" + echo "已备份:$config_backup" +fi +if [ -f "$AUTH_PATH" ]; then + auth_backup="$BACKUP_DIR/$(basename "$AUTH_PATH").bak-$timestamp" + cp "$AUTH_PATH" "$auth_backup" + echo "已备份:$auth_backup" +fi + +"$PYTHON_BIN" - "$CONFIG_PATH" "$AUTH_PATH" "$BASE_URL" "$API_KEY" "$ENABLE_WEBSOCKET" "$CONFIG_TOML_TEMPLATE" <<'PY' +import json +import sys +from pathlib import Path + +config_path = Path(sys.argv[1]) +auth_path = Path(sys.argv[2]) +base_url = sys.argv[3].rstrip("/") +api_key = sys.argv[4].strip() +enable_ws = sys.argv[5].lower() == "true" +template = sys.argv[6] + +if not api_key: + raise RuntimeError("缺少 API Key。") +if not base_url: + raise RuntimeError("缺少 Base URL。") + +if auth_path.exists() and auth_path.read_text(encoding="utf-8").strip(): + try: + json.loads(auth_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError("auth.json 不是合法 JSON,请手动合并后重试。") from exc + +rendered_config = template.replace("{{LOOK2EYE_BASE_URL}}", base_url) +rendered_config = rendered_config.replace("{{LOOK2EYE_ENABLE_WEBSOCKET}}", "true" if enable_ws else "false") +rendered_config = rendered_config.replace("\\r\\n", "\\n").replace("\\r", "\\n").rstrip("\\n") + "\\n" +auth_text = json.dumps({"OPENAI_API_KEY": api_key}, ensure_ascii=False, separators=(",", ":")) + "\\n" + +config_path.write_text(rendered_config, encoding="utf-8") +auth_path.write_text(auth_text, encoding="utf-8") + +if config_path.read_text(encoding="utf-8") != rendered_config: + raise RuntimeError("config.toml 写入后回读不一致,请检查磁盘权限或安全软件拦截。") +actual_auth = json.loads(auth_path.read_text(encoding="utf-8")) +if actual_auth.get("OPENAI_API_KEY") != api_key: + raise RuntimeError("auth.json 写入后 API Key 校验失败。") +PY + +echo "回读校验:已确认配置和认证文件写入成功。" +echo "已完成,${siteName} Codex CLI 配置已更新。正在自动关闭 Codex..." +stop_codex_processes +${clientInstall} +ensure_codex_client +` +} + +function buildCodexPowerShellPayload(input: Required>): string { + const siteName = input.siteName + const baseUrl = resolveBaseUrl(input.baseUrl) + const template = codexConfigTemplate(input) + + return `$ErrorActionPreference = "Stop" + +$BaseUrl = ${powershellSingleQuote(baseUrl)} +$ApiKey = ${powershellSingleQuote(input.apiKey)} +$EnableWebSocket = $false +$ConfigTomlTemplate = ${powershellSingleQuote(template)} +$SiteName = ${powershellSingleQuote(siteName)} + +function Stop-CodexProcesses { + if ($env:LOOK2EYE_SKIP_CODEX_PROCESS_CLOSE -eq "1") { + Write-Host "已跳过结束 Codex 进程。" + return + } + + $processes = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.ProcessName -ieq "codex" -and $_.Id -ne $PID + }) + if ($processes.Count -eq 0) { + Write-Host "未发现正在运行的 Codex 进程;重新打开 Codex 即可使用新配置。" + return + } + + $stopped = 0 + foreach ($process in $processes) { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + $stopped += 1 + } + Write-Host ("已结束 Codex 进程:{0} 个。" -f $stopped) +} + +function New-BackupStamp { + param([string[]]$Paths, [string]$BackupDir) + $base = Get-Date -Format "yyyyMMdd-HHmmss" + $stamp = $base + $counter = 2 + while ($true) { + $exists = $false + foreach ($path in $Paths) { + $name = Split-Path -Leaf $path + if (Test-Path -LiteralPath (Join-Path $BackupDir "$name.bak-$stamp")) { + $exists = $true + break + } + } + if (-not $exists) { return $stamp } + $stamp = "{0}-{1:D2}" -f $base, $counter + $counter++ + } +} + +function Backup-File { + param([string]$Path, [string]$BackupDir, [string]$Timestamp) + if (Test-Path -LiteralPath $Path) { + New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null + $backup = Join-Path $BackupDir "$((Split-Path -Leaf $Path)).bak-$Timestamp" + Copy-Item -LiteralPath $Path -Destination $backup -Force + Write-Host "已备份:$backup" + } +} + +function Get-CodexBackupSets { + param([string]$ConfigPath, [string]$AuthPath, [string]$BackupDir) + $sets = @{} + foreach ($item in @(@{ Path = $ConfigPath; Kind = "Config" }, @{ Path = $AuthPath; Kind = "Auth" })) { + $name = Split-Path -Leaf $item.Path + foreach ($dir in @($BackupDir, (Split-Path -Parent $item.Path))) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + foreach ($file in Get-ChildItem -LiteralPath $dir -File -Filter "$name.bak-*") { + $stamp = $file.Name.Substring(("$name.bak-").Length) + if (-not $sets.ContainsKey($stamp)) { + $sets[$stamp] = [ordered]@{ Timestamp = $stamp; Config = $null; Auth = $null; LastWriteTime = $file.LastWriteTime } + } + $entry = $sets[$stamp] + $entry[$item.Kind] = $file + if ($file.LastWriteTime -gt $entry.LastWriteTime) { $entry.LastWriteTime = $file.LastWriteTime } + } + } + } + return @($sets.Values | ForEach-Object { [pscustomobject]$_ } | Sort-Object LastWriteTime -Descending) +} + +function Restore-CodexBackup { + param([string]$ConfigPath, [string]$AuthPath, [string]$BackupDir) + $items = Get-CodexBackupSets $ConfigPath $AuthPath $BackupDir + if ($items.Count -eq 0) { throw "未找到之前的 Codex config/auth 备份。" } + Write-Host "" + Write-Host "可用备份:" + for ($i = 0; $i -lt $items.Count; $i++) { + $parts = @() + if ($null -ne $items[$i].Config) { $parts += "配置" } + if ($null -ne $items[$i].Auth) { $parts += "认证" } + Write-Host ("{0}) {1} [{2}] 文件时间 {3}" -f ($i + 1), $items[$i].Timestamp, ($parts -join "+"), $items[$i].LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss")) + } + $choice = Read-Host "请选择备份序号" + $index = 0 + if (-not [int]::TryParse($choice.Trim(), [ref]$index) -or $index -lt 1 -or $index -gt $items.Count) { + throw "备份序号无效。" + } + $selected = $items[$index - 1] + if ($null -ne $selected.Config) { + Copy-Item -LiteralPath $selected.Config.FullName -Destination $ConfigPath -Force + Write-Host "已恢复:$ConfigPath" + } + if ($null -ne $selected.Auth) { + Copy-Item -LiteralPath $selected.Auth.FullName -Destination $AuthPath -Force + Write-Host "已恢复:$AuthPath" + } +} + +function Resolve-SetupAction { + param([string[]]$Arguments) + if ($null -eq $Arguments -or $Arguments.Count -eq 0) { return "apply" } + switch ($Arguments[0].Trim().ToLowerInvariant()) { + "" { return "apply" } + "1" { return "apply" } + "apply" { return "apply" } + "--apply" { return "apply" } + "2" { return "restore" } + "restore" { return "restore" } + "--restore" { return "restore" } + "/restore" { return "restore" } + default { throw "参数无效:$($Arguments[0])。直接运行会配置 $SiteName;恢复备份请使用 restore 参数。" } + } +} + +try { + if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { throw "USERPROFILE 为空。" } + $codexDir = Join-Path $env:USERPROFILE ".codex" + $configPath = Join-Path $codexDir "config.toml" + $authPath = Join-Path $codexDir "auth.json" + $backupDir = Join-Path $codexDir "backups" + + if ((Resolve-SetupAction -Arguments $args) -eq "restore") { + Restore-CodexBackup $configPath $authPath $backupDir + Stop-CodexProcesses + exit 0 + } + + if ([string]::IsNullOrWhiteSpace($ApiKey)) { throw "缺少 API Key。" } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { throw "缺少 Base URL。" } + + New-Item -ItemType Directory -Force -Path $codexDir | Out-Null + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + if (Test-Path -LiteralPath $authPath) { + $rawAuth = [System.IO.File]::ReadAllText($authPath) + if (-not [string]::IsNullOrWhiteSpace($rawAuth)) { + $null = $rawAuth | ConvertFrom-Json -ErrorAction Stop + } + } + + $newConfig = $ConfigTomlTemplate.Replace("{{LOOK2EYE_BASE_URL}}", $BaseUrl.Trim().TrimEnd("/")) + $newConfig = $newConfig.Replace("{{LOOK2EYE_ENABLE_WEBSOCKET}}", $EnableWebSocket.ToString().ToLowerInvariant()) + $newConfig = $newConfig.TrimEnd([char[]]@([char]13, [char]10)) + [Environment]::NewLine + $newAuth = ([ordered]@{ OPENAI_API_KEY = $ApiKey.Trim() } | ConvertTo-Json -Compress) + [Environment]::NewLine + + $timestamp = New-BackupStamp -Paths @($configPath, $authPath) -BackupDir $backupDir + Backup-File $configPath $backupDir $timestamp + Backup-File $authPath $backupDir $timestamp + + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($configPath, $newConfig, $utf8NoBom) + [System.IO.File]::WriteAllText($authPath, $newAuth, $utf8NoBom) + + if ([System.IO.File]::ReadAllText($configPath) -ne $newConfig) { throw "config.toml 写入后回读不一致。" } + $actualAuth = [System.IO.File]::ReadAllText($authPath) | ConvertFrom-Json -ErrorAction Stop + if ($actualAuth.OPENAI_API_KEY -ne $ApiKey.Trim()) { throw "auth.json 写入后 API Key 校验失败。" } + + Write-Host "回读校验:已确认配置和认证文件写入成功。" + Write-Host "已完成,$SiteName Codex CLI 配置已更新。正在自动关闭 Codex..." + Stop-CodexProcesses + exit 0 +} catch { + Write-Host "失败:$($_.Exception.Message)" -ForegroundColor Red + exit 1 +} +` +} + +function windowsNodeRuntimeNotice(clientLabel: string): string { + return renderConfigScriptTemplate(WINDOWS_NODE_RUNTIME_NOTICE_TEMPLATE, { + CLIENT_LABEL: clientLabel + }) +} + +function windowsClientInstallNotice(spec: ClientInstallSpec): string { + const commandName = spec.commandName.toLowerCase().endsWith('.cmd') + ? spec.commandName + : `${spec.commandName}.cmd` + return renderConfigScriptTemplate(WINDOWS_CLIENT_INSTALL_NOTICE_TEMPLATE, { + CLIENT_LABEL: spec.label, + COMMAND_NAME: commandName, + NPM_PACKAGE: spec.npmPackage + }) +} + +function buildPowerShellPayloadExtractorCommand(): string { + return buildEncodedPowerShellCommand(`$ErrorActionPreference = "Stop" +$scriptPath = $env:LOOK2EYE_SETUP_SCRIPT_PATH +$payloadPath = $env:LOOK2EYE_SETUP_PAYLOAD +$marker = $env:LOOK2EYE_SETUP_MARKER +$bytes = [System.IO.File]::ReadAllBytes($scriptPath) +$markerBytes = [System.Text.Encoding]::ASCII.GetBytes($marker) +$index = -1 +for ($i = $bytes.Length - $markerBytes.Length; $i -ge 0; $i--) { + $matched = $true + for ($j = 0; $j -lt $markerBytes.Length; $j++) { + if ($bytes[$i + $j] -ne $markerBytes[$j]) { + $matched = $false + break + } + } + if ($matched) { + $index = $i + break + } +} +if ($index -lt 0) { throw "PowerShell payload marker not found." } +$start = $index + $markerBytes.Length +while ($start -lt $bytes.Length -and ($bytes[$start] -eq 13 -or $bytes[$start] -eq 10)) { + $start++ +} +$bom = [byte[]](0xEF, 0xBB, 0xBF) +$payloadLength = $bytes.Length - $start +$output = New-Object byte[] ($bom.Length + $payloadLength) +[System.Array]::Copy($bom, 0, $output, 0, $bom.Length) +[System.Array]::Copy($bytes, $start, $output, $bom.Length, $payloadLength) +[System.IO.File]::WriteAllBytes($payloadPath, $output) +`) +} + +function buildEmbeddedPowerShellBatch(input: { + marker: string + payload: string + tempName: string + passArgs?: boolean + successMessage: string + successHint: string + failureMessage: string + failureHint: string + nodeClientLabel: string + clientInstall: ClientInstallSpec +}): string { + const extractor = buildPowerShellPayloadExtractorCommand() + const forwardedArgs = input.passArgs ? ' %*' : '' + + return renderConfigScriptTemplate(WINDOWS_EMBEDDED_POWERSHELL_BATCH_TEMPLATE, { + TEMP_NAME: input.tempName, + MARKER: input.marker, + EXTRACTOR_COMMAND: extractor, + FORWARDED_ARGS: forwardedArgs, + SUCCESS_MESSAGE: input.successMessage, + SUCCESS_HINT: input.successHint, + FAILURE_MESSAGE: input.failureMessage, + FAILURE_HINT: input.failureHint, + NODE_RUNTIME_NOTICE: windowsNodeRuntimeNotice(input.nodeClientLabel), + CLIENT_INSTALL_NOTICE: windowsClientInstallNotice(input.clientInstall), + POWERSHELL_PAYLOAD: input.payload + }) +} + +function buildCodexBatchScript(input: Required>): string { + return buildEmbeddedPowerShellBatch({ + marker: '__LOOK2EYE_CODEX_PS1__', + payload: buildCodexPowerShellPayload(input), + tempName: 'look2eye-codex-config', + passArgs: true, + successMessage: `${input.siteName} Codex CLI config/restore completed.`, + successHint: 'Reopen Codex or the target client to load the new config.', + failureMessage: `${input.siteName} Codex CLI config/restore did not complete.`, + failureHint: 'Check the error above. If Codex is still open, close it manually and retry.', + nodeClientLabel: 'Codex CLI', + clientInstall: CODEX_INSTALL_SPEC + }) +} + +function resolveClaudeBase(_input: Required> & Pick): string { + return resolveBaseUrl(_input.baseUrl) +} + +function buildClaudeShellScript(input: Required> & Pick): string { + const siteName = input.siteName + const baseUrl = resolveClaudeBase(input) + const clientInstall = shellClientInstallNotice(CLAUDE_CODE_INSTALL_SPEC) + + return `#!/usr/bin/env sh +set -eu + +look2eye_setup_result() { + rc=$? + if [ "$rc" -eq 0 ]; then + printf '\\n' + echo "============================================================" + echo "[LOOK2EYE SETUP SUCCESS] ${siteName} Claude Code 配置已完成。" + echo "请重新打开 Claude Code 或对应客户端,让新配置生效。" + echo "============================================================" + else + printf '\\n' >&2 + echo "============================================================" >&2 + echo "[LOOK2EYE SETUP FAILED] ${siteName} Claude Code 配置未完成或未完全生效。退出码:$rc" >&2 + echo "请查看上方失败原因;如 settings.json 无法自动合并,请手动处理后重试。" >&2 + echo "============================================================" >&2 + fi + trap - EXIT + exit "$rc" +} +trap look2eye_setup_result EXIT + +BASE_URL=${shellQuote(baseUrl)} +API_KEY=${shellQuote(input.apiKey)} +CLAUDE_DIR="$HOME/.claude" +SETTINGS_PATH="$CLAUDE_DIR/settings.json" +BACKUP_DIR="$CLAUDE_DIR/backups" + +PYTHON_BIN= +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python3 +elif command -v python >/dev/null 2>&1 && python -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python +else + echo "Failed: a working python3 or python is required to merge existing Claude Code config safely." >&2 + exit 1 +fi + +mkdir -p "$CLAUDE_DIR" "$BACKUP_DIR" + +unique_backup_path() { + path=$1 + backup_dir=$2 + timestamp=$3 + name=$(basename "$path") + backup="$backup_dir/$name.bak-$timestamp" + counter=2 + while [ -e "$backup" ]; do + backup="$backup_dir/$name.bak-$timestamp-$(printf "%02d" "$counter")" + counter=$((counter + 1)) + done + printf '%s\\n' "$backup" +} + +timestamp=$(date +"%Y%m%d-%H%M%S") +if [ -f "$SETTINGS_PATH" ]; then + backup_path=$(unique_backup_path "$SETTINGS_PATH" "$BACKUP_DIR" "$timestamp") + cp "$SETTINGS_PATH" "$backup_path" + echo "Backup: $backup_path" +fi + +"$PYTHON_BIN" - "$SETTINGS_PATH" "$BASE_URL" "$API_KEY" <<'PY' +import json +import sys +from pathlib import Path + +settings_path = Path(sys.argv[1]) +base_url = sys.argv[2].rstrip("/") +api_key = sys.argv[3] + +if not api_key: + raise RuntimeError("Missing API key.") +if not base_url: + raise RuntimeError("Missing base URL.") + +settings = {} +if settings_path.exists() and settings_path.read_text(encoding="utf-8").strip(): + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError("settings.json is not valid JSON. Please merge it manually.") from exc + if not isinstance(settings, dict): + raise RuntimeError("settings.json must contain a JSON object.") + +env = settings.get("env") +if not isinstance(env, dict): + env = {} +env.update({ + "ANTHROPIC_BASE_URL": base_url, + "ANTHROPIC_AUTH_TOKEN": api_key, + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "CLAUDE_CODE_ATTRIBUTION_HEADER": "0", +}) +settings["env"] = env + +settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\\n", encoding="utf-8") +PY + +echo "Done. ${siteName} Claude Code config has been updated." +${clientInstall} +ensure_claude_client +` +} + +function buildClaudePowerShellPayload(input: Required> & Pick): string { + const siteName = input.siteName + const baseUrl = resolveClaudeBase(input) + + return `$ErrorActionPreference = "Stop" + +$BaseUrl = ${powershellSingleQuote(baseUrl)} +$ApiKey = ${powershellSingleQuote(input.apiKey)} +$SiteName = ${powershellSingleQuote(siteName)} + +function ConvertTo-OrderedMap { + param([object]$Value) + $map = [ordered]@{} + if ($null -eq $Value) { return $map } + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in $Value.Keys) { $map[[string]$key] = $Value[$key] } + return $map + } + foreach ($prop in $Value.PSObject.Properties) { + $map[$prop.Name] = $prop.Value + } + return $map +} + +function Backup-File { + param( + [string]$Path, + [string]$BackupDir, + [string]$Timestamp + ) + if (Test-Path -LiteralPath $Path) { + New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null + $backup = Get-UniqueBackupPath $Path $BackupDir $Timestamp + Copy-Item -LiteralPath $Path -Destination $backup + Write-Host "Backup: $backup" + } +} + +function Get-UniqueBackupPath { + param( + [string]$Path, + [string]$BackupDir, + [string]$Timestamp + ) + $name = Split-Path -Leaf $Path + $backup = Join-Path $BackupDir "$name.bak-$Timestamp" + $counter = 2 + while (Test-Path -LiteralPath $backup) { + $backup = Join-Path $BackupDir ("{0}.bak-{1}-{2:D2}" -f $name, $Timestamp, $counter) + $counter++ + } + return $backup +} + +function Write-Utf8NoBom { + param( + [string]$Path, + [string]$Text + ) + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Text, $encoding) +} + +try { + if ([string]::IsNullOrWhiteSpace($ApiKey)) { throw "Missing API key." } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { throw "Missing base URL." } + + $targetHome = $env:USERPROFILE + if ([string]::IsNullOrWhiteSpace($targetHome)) { throw "USERPROFILE is empty." } + + $claudeDir = Join-Path $targetHome ".claude" + $settingsPath = Join-Path $claudeDir "settings.json" + $backupDir = Join-Path $claudeDir "backups" + $settings = [ordered]@{} + + if (Test-Path -LiteralPath $settingsPath) { + $raw = [System.IO.File]::ReadAllText($settingsPath) + if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { + $settings = ConvertTo-OrderedMap ($raw | ConvertFrom-Json) + } catch { + throw "settings.json is not valid JSON. Please merge it manually." + } + } + } + + $envMap = [ordered]@{} + if ($settings.Contains("env")) { + $envMap = ConvertTo-OrderedMap $settings["env"] + } + $envMap["ANTHROPIC_BASE_URL"] = $BaseUrl.Trim().TrimEnd("/") + $envMap["ANTHROPIC_AUTH_TOKEN"] = $ApiKey + $envMap["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + $envMap["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "0" + $settings["env"] = $envMap + + Write-Host "Applying $SiteName Claude Code config..." + Write-Host "Settings: $settingsPath" + Write-Host "Base: $($BaseUrl.Trim().TrimEnd('/'))" + + New-Item -ItemType Directory -Force -Path $claudeDir | Out-Null + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + Backup-File $settingsPath $backupDir $timestamp + Write-Utf8NoBom $settingsPath (($settings | ConvertTo-Json -Depth 50) + [Environment]::NewLine) + + Write-Host "Done. $SiteName Claude Code config has been updated." + exit 0 +} catch { + Write-Host "Failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} +` +} + +function buildClaudeBatchScript(input: Required> & Pick): string { + return buildEmbeddedPowerShellBatch({ + marker: '__LOOK2EYE_CLAUDE_CODE_PS1__', + payload: buildClaudePowerShellPayload(input), + tempName: 'look2eye-claude-code-config', + successMessage: `${input.siteName} Claude Code config completed.`, + successHint: 'Reopen Claude Code or the target client to load the new config.', + failureMessage: `${input.siteName} Claude Code config did not complete.`, + failureHint: 'Check the error above. If settings.json cannot be merged automatically, handle it manually and retry.', + nodeClientLabel: 'Claude Code', + clientInstall: CLAUDE_CODE_INSTALL_SPEC + }) +} + +function buildOpenCodeShellScript(input: Required>): string { + const siteName = input.siteName + const baseUrl = resolveBaseUrl(input.baseUrl) + const clientInstall = shellClientInstallNotice(OPENCODE_INSTALL_SPEC) + + return `#!/usr/bin/env sh +set -eu + +look2eye_setup_result() { + rc=$? + if [ "$rc" -eq 0 ]; then + printf '\\n' + echo "============================================================" + echo "[LOOK2EYE SETUP SUCCESS] ${siteName} OpenCode 配置已完成。" + echo "请重新打开 OpenCode 或对应客户端,让新配置生效。" + echo "============================================================" + else + printf '\\n' >&2 + echo "============================================================" >&2 + echo "[LOOK2EYE SETUP FAILED] ${siteName} OpenCode 配置未完成或未完全生效。退出码:$rc" >&2 + echo "请查看上方失败原因;如 opencode.json 无法自动合并,请手动处理后重试。" >&2 + echo "============================================================" >&2 + fi + trap - EXIT + exit "$rc" +} +trap look2eye_setup_result EXIT + +BASE_URL=${shellQuote(baseUrl)} +API_KEY=${shellQuote(input.apiKey)} +OPENCODE_DIR="$HOME/.config/opencode" +CONFIG_PATH="$OPENCODE_DIR/opencode.json" +BACKUP_DIR="$OPENCODE_DIR/backups" + +PYTHON_BIN= +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python3 +elif command -v python >/dev/null 2>&1 && python -c 'import sys' >/dev/null 2>&1; then + PYTHON_BIN=python +else + echo "Failed: a working python3 or python is required to merge existing OpenCode config safely." >&2 + exit 1 +fi + +mkdir -p "$OPENCODE_DIR" "$BACKUP_DIR" + +unique_backup_path() { + path=$1 + backup_dir=$2 + timestamp=$3 + name=$(basename "$path") + backup="$backup_dir/$name.bak-$timestamp" + counter=2 + while [ -e "$backup" ]; do + backup="$backup_dir/$name.bak-$timestamp-$(printf "%02d" "$counter")" + counter=$((counter + 1)) + done + printf '%s\\n' "$backup" +} + +timestamp=$(date +"%Y%m%d-%H%M%S") +if [ -f "$CONFIG_PATH" ]; then + backup_path=$(unique_backup_path "$CONFIG_PATH" "$BACKUP_DIR" "$timestamp") + cp "$CONFIG_PATH" "$backup_path" + echo "Backup: $backup_path" +fi + +"$PYTHON_BIN" - "$CONFIG_PATH" "$BASE_URL" "$API_KEY" <<'PY' +import json +import sys +from pathlib import Path + +config_path = Path(sys.argv[1]) +base_url = sys.argv[2].rstrip("/") +api_key = sys.argv[3] +default_models = json.loads(r'''${OPENCODE_DEFAULT_MODELS_JSON}''') + +if not api_key: + raise RuntimeError("Missing API key.") +if not base_url: + raise RuntimeError("Missing base URL.") + +config = {} +if config_path.exists() and config_path.read_text(encoding="utf-8").strip(): + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError("opencode.json is not valid JSON. Please merge it manually.") from exc + if not isinstance(config, dict): + raise RuntimeError("opencode.json must contain a JSON object.") + +provider = config.get("provider") +if not isinstance(provider, dict): + provider = {} +openai = provider.get("openai") +if not isinstance(openai, dict): + openai = {} +options = openai.get("options") +if not isinstance(options, dict): + options = {} +options["baseURL"] = base_url +options["apiKey"] = api_key +openai["options"] = options + +models = openai.get("models") +if not isinstance(models, dict): + models = {} +for model_name, model_config in default_models.items(): + existing_model = models.get(model_name) + if isinstance(existing_model, dict): + existing_model["variants"] = model_config.get("variants", {}) + models[model_name] = existing_model + else: + models[model_name] = model_config +openai["models"] = models +provider["openai"] = openai +config["provider"] = provider +config.setdefault("$schema", "https://opencode.ai/config.json") + +agent = config.get("agent") +if not isinstance(agent, dict): + agent = {} +for section_name in ("build", "plan"): + section = agent.get(section_name) + if not isinstance(section, dict): + section = {} + opts = section.get("options") + if not isinstance(opts, dict): + opts = {} + opts["store"] = False + section["options"] = opts + agent[section_name] = section +config["agent"] = agent + +config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\\n", encoding="utf-8") +PY + +echo "Done. ${siteName} OpenCode config has been updated." +${clientInstall} +ensure_opencode_client +` +} + +function buildOpenCodePowerShellPayload(input: Required>): string { + const siteName = input.siteName + const baseUrl = resolveBaseUrl(input.baseUrl) + + return `$ErrorActionPreference = "Stop" + +$BaseUrl = ${powershellSingleQuote(baseUrl)} +$ApiKey = ${powershellSingleQuote(input.apiKey)} +$SiteName = ${powershellSingleQuote(siteName)} +$ModelsJson = @' +${OPENCODE_DEFAULT_MODELS_JSON} +'@ + +function ConvertTo-OrderedMap { + param([object]$Value) + $map = [ordered]@{} + if ($null -eq $Value) { return $map } + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in $Value.Keys) { $map[[string]$key] = $Value[$key] } + return $map + } + foreach ($prop in $Value.PSObject.Properties) { + $map[$prop.Name] = $prop.Value + } + return $map +} + +function Backup-File { + param( + [string]$Path, + [string]$BackupDir, + [string]$Timestamp + ) + if (Test-Path -LiteralPath $Path) { + New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null + $backup = Get-UniqueBackupPath $Path $BackupDir $Timestamp + Copy-Item -LiteralPath $Path -Destination $backup + Write-Host "Backup: $backup" + } +} + +function Get-UniqueBackupPath { + param( + [string]$Path, + [string]$BackupDir, + [string]$Timestamp + ) + $name = Split-Path -Leaf $Path + $backup = Join-Path $BackupDir "$name.bak-$Timestamp" + $counter = 2 + while (Test-Path -LiteralPath $backup) { + $backup = Join-Path $BackupDir ("{0}.bak-{1}-{2:D2}" -f $name, $Timestamp, $counter) + $counter++ + } + return $backup +} + +function Write-Utf8NoBom { + param( + [string]$Path, + [string]$Text + ) + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Text, $encoding) +} + +function Ensure-AgentStoreFalse { + param([object]$Config) + $agent = [ordered]@{} + if ($Config.Contains("agent")) { + $agent = ConvertTo-OrderedMap $Config["agent"] + } + foreach ($sectionName in @("build", "plan")) { + $section = [ordered]@{} + if ($agent.Contains($sectionName)) { + $section = ConvertTo-OrderedMap $agent[$sectionName] + } + $options = [ordered]@{} + if ($section.Contains("options")) { + $options = ConvertTo-OrderedMap $section["options"] + } + $options["store"] = $false + $section["options"] = $options + $agent[$sectionName] = $section + } + $Config["agent"] = $agent +} + +try { + if ([string]::IsNullOrWhiteSpace($ApiKey)) { throw "Missing API key." } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { throw "Missing base URL." } + + $targetHome = $env:USERPROFILE + if ([string]::IsNullOrWhiteSpace($targetHome)) { throw "USERPROFILE is empty." } + + $openCodeDir = Join-Path (Join-Path $targetHome ".config") "opencode" + $configPath = Join-Path $openCodeDir "opencode.json" + $backupDir = Join-Path $openCodeDir "backups" + $config = [ordered]@{} + + if (Test-Path -LiteralPath $configPath) { + $raw = [System.IO.File]::ReadAllText($configPath) + if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { + $config = ConvertTo-OrderedMap ($raw | ConvertFrom-Json) + } catch { + throw "opencode.json is not valid JSON. Please merge it manually." + } + } + } + + $provider = [ordered]@{} + if ($config.Contains("provider")) { + $provider = ConvertTo-OrderedMap $config["provider"] + } + $openai = [ordered]@{} + if ($provider.Contains("openai")) { + $openai = ConvertTo-OrderedMap $provider["openai"] + } + $options = [ordered]@{} + if ($openai.Contains("options")) { + $options = ConvertTo-OrderedMap $openai["options"] + } + $options["baseURL"] = $BaseUrl.Trim().TrimEnd("/") + $options["apiKey"] = $ApiKey + $openai["options"] = $options + + $models = [ordered]@{} + if ($openai.Contains("models")) { + $models = ConvertTo-OrderedMap $openai["models"] + } + $defaultModels = ConvertTo-OrderedMap ($ModelsJson | ConvertFrom-Json) + foreach ($modelName in $defaultModels.Keys) { + if (-not $models.Contains($modelName)) { + $models[$modelName] = $defaultModels[$modelName] + } else { + $model = ConvertTo-OrderedMap $models[$modelName] + $defaultModel = ConvertTo-OrderedMap $defaultModels[$modelName] + if ($defaultModel.Contains("variants")) { + $model["variants"] = ConvertTo-OrderedMap $defaultModel["variants"] + } + $models[$modelName] = $model + } + } + $openai["models"] = $models + $provider["openai"] = $openai + $config["provider"] = $provider + if (-not $config.Contains('$schema')) { + $config['$schema'] = "https://opencode.ai/config.json" + } + Ensure-AgentStoreFalse $config + + Write-Host "Applying $SiteName OpenCode config..." + Write-Host "Config: $configPath" + Write-Host "Base: $($BaseUrl.Trim().TrimEnd('/'))" + + New-Item -ItemType Directory -Force -Path $openCodeDir | Out-Null + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + Backup-File $configPath $backupDir $timestamp + Write-Utf8NoBom $configPath (($config | ConvertTo-Json -Depth 80) + [Environment]::NewLine) + + Write-Host "Done. $SiteName OpenCode config has been updated." + exit 0 +} catch { + Write-Host "Failed: $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} +` +} + +function buildOpenCodeBatchScript(input: Required>): string { + return buildEmbeddedPowerShellBatch({ + marker: '__LOOK2EYE_OPENCODE_PS1__', + payload: buildOpenCodePowerShellPayload(input), + tempName: 'look2eye-opencode-config', + successMessage: `${input.siteName} OpenCode config completed.`, + successHint: 'Reopen OpenCode or the target client to load the new config.', + failureMessage: `${input.siteName} OpenCode config did not complete.`, + failureHint: 'Check the error above. If opencode.json cannot be merged automatically, handle it manually and retry.', + nodeClientLabel: 'OpenCode', + clientInstall: OPENCODE_INSTALL_SPEC + }) +} + +function buildClaudePayload(input: Required> & Pick): ConfigPayload { + const baseUrl = resolveClaudeBase(input) + const env = { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: input.apiKey, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_ATTRIBUTION_HEADER: '0' + } + const envContent = `export ANTHROPIC_BASE_URL=${shellQuote(baseUrl)} +export ANTHROPIC_AUTH_TOKEN=${shellQuote(input.apiKey)} +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export CLAUDE_CODE_ATTRIBUTION_HEADER=0` + const settingsJSON = json({ + env + }) + + return { + label: 'Claude Code', + env, + files: [ + { path: '.look2eye/claude-code.env', content: `# ${input.siteName} Claude Code environment\n${envContent}\n` }, + { path: '.claude/settings.json', content: settingsJSON } + ] + } +} + +function buildOpenCodePayload(input: Required> & Pick): ConfigPayload { + const platform = input.platform || 'anthropic' + const providerID = platform === 'antigravity' ? 'antigravity-claude' : platform + const baseURL = platform === 'gemini' + ? resolveGeminiBase(input.baseUrl) + : platform === 'antigravity' + ? resolveAntigravityBase(input.baseUrl) + : resolveAPIBase(input.baseUrl) + const model = platform === 'gemini' + ? 'gemini-2.0-flash' + : platform === 'openai' + ? 'gpt-5.5' + : 'claude-opus-4-6-thinking' + + const config = { + $schema: 'https://opencode.ai/config.json', + provider: { + [providerID]: { + npm: platform === 'gemini' ? '@ai-sdk/google' : platform === 'openai' ? '@ai-sdk/openai' : '@ai-sdk/anthropic', + name: input.siteName, + options: { + baseURL, + apiKey: input.apiKey + }, + models: { + [model]: { + name: model + } + } + } + } + } + + return { + label: 'OpenCode', + files: [ + { path: '.config/opencode/opencode.json', content: json(config) } + ] + } +} + +function buildPayload(input: ConfigScriptInput): ConfigPayload { + const base = { + baseUrl: input.baseUrl, + apiKey: input.apiKey, + siteName: input.siteName || CONFIG_SCRIPT_SITE_NAME + } + + switch (input.client) { + case 'codex': + throw new Error('Codex CLI uses a dedicated setup script generator.') + case 'claude': + return buildClaudePayload({ ...base, platform: input.platform }) + case 'opencode': + return buildOpenCodePayload({ ...base, platform: input.platform }) + } +} + +function buildShellScript(payload: ConfigPayload, siteName: string): string { + const writeFileCommands = payload.files.map((file) => { + const target = `$HOME/${file.path}` + const dir = file.path.split('/').slice(0, -1).join('/') + return `mkdir -p "$HOME/${dir}" +cat > "${target}" <<'LOOK2EYE_CONFIG_EOF' +${file.content} +LOOK2EYE_CONFIG_EOF` + }).join('\n\n') + + const profileCommands = payload.files.some((file) => file.path === '.look2eye/claude-code.env') + ? ` +PROFILE="$HOME/.zshrc" +if [ -n "\${BASH_VERSION:-}" ]; then + PROFILE="$HOME/.bashrc" +fi +touch "$PROFILE" +SOURCE_LINE='. "$HOME/.look2eye/claude-code.env"' +if ! grep -qxF "$SOURCE_LINE" "$PROFILE"; then + printf '\\n# ${siteName} Claude Code\\n%s\\n' "$SOURCE_LINE" >> "$PROFILE" +fi` + : '' + + return `#!/usr/bin/env sh +set -eu + +echo "Installing ${siteName} ${payload.label} configuration..." + +${writeFileCommands}${profileCommands} + +echo "Done. Restart your terminal or source your shell profile if environment variables were updated." +` +} + +function buildPowerShellScript(payload: ConfigPayload, siteName: string): string { + const writeFileCommands = payload.files.map((file) => { + const windowsPath = file.path.replace(/\//g, '\\') + return `$target = Join-Path $env:USERPROFILE ${JSON.stringify(windowsPath)} +New-Item -ItemType Directory -Force -Path (Split-Path $target) | Out-Null +@' +${file.content} +'@ | Set-Content -Encoding UTF8 -Path $target` + }).join('\n\n') + + const envCommands = payload.env + ? Object.entries(payload.env) + .map(([key, value]) => `[Environment]::SetEnvironmentVariable(${JSON.stringify(key)}, ${JSON.stringify(value)}, 'User')`) + .join('\n') + : '' + + return `$ErrorActionPreference = 'Stop' +Write-Host "Installing ${siteName} ${payload.label} configuration..." + +${writeFileCommands}${envCommands ? `\n\n${envCommands}` : ''} + +Write-Host "Done. Restart your terminal for environment variable changes to take effect." +` +} + +function toBase64UTF16LE(value: string): string { + let binary = '' + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + binary += String.fromCharCode(code & 0xff, code >> 8) + } + return btoa(binary) +} + +function buildEncodedPowerShellCommand(script: string): string { + return toBase64UTF16LE(script.replace(/\r\n/g, '\n').replace(/\r/g, '\n')) +} + +function buildBatchScript(payload: ConfigPayload, siteName: string): string { + const psScript = buildPowerShellScript(payload, siteName) + const encoded = buildEncodedPowerShellCommand(psScript) + return renderConfigScriptTemplate(WINDOWS_BASIC_BATCH_TEMPLATE, { + SITE_NAME: siteName, + PAYLOAD_LABEL: payload.label, + ENCODED_COMMAND: encoded + }) +} + +export function getConfigScriptOS(): ConfigScriptOS { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } } + const platform = nav.userAgentData?.platform || navigator.platform || '' + return /win/i.test(platform) ? 'win' : 'mac' +} + +export function buildAPIKeyConfigScript(input: ConfigScriptInput): { filename: string; content: string; os: ConfigScriptOS } { + const os = input.os || getConfigScriptOS() + const siteName = input.siteName || CONFIG_SCRIPT_SITE_NAME + const filenameClient = input.client === 'claude' ? 'claude-code' : input.client + const extension = os === 'win' ? 'bat' : 'sh' + const content = input.client === 'codex' + ? os === 'win' + ? buildCodexBatchScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName }) + : buildCodexShellScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName }) + : input.client === 'claude' + ? os === 'win' + ? buildClaudeBatchScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName, platform: input.platform }) + : buildClaudeShellScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName, platform: input.platform }) + : input.client === 'opencode' + ? os === 'win' + ? buildOpenCodeBatchScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName }) + : buildOpenCodeShellScript({ baseUrl: input.baseUrl, apiKey: input.apiKey, siteName }) + : os === 'win' + ? buildBatchScript(buildPayload({ ...input, siteName }), siteName) + : buildShellScript(buildPayload({ ...input, siteName }), siteName) + + return { + filename: `${siteName}-${filenameClient}-config.${extension}`, + content, + os + } +} + +export function isConfigScriptClientAvailable(input: Pick): boolean { + return !!input.platform +} + +export function downloadAPIKeyConfigScript(input: ConfigScriptInput): void { + const script = buildAPIKeyConfigScript(input) + const mime = script.os === 'win' ? 'application/x-bat' : 'text/x-shellscript' + const content = script.os === 'win' + ? script.content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n') + : script.content + const blob = new Blob([content], { type: `${mime};charset=utf-8` }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = script.filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/frontend/src/utils/configScriptTemplates.ts b/frontend/src/utils/configScriptTemplates.ts new file mode 100644 index 00000000000..e936fd28935 --- /dev/null +++ b/frontend/src/utils/configScriptTemplates.ts @@ -0,0 +1,121 @@ +export type ConfigScriptTemplateValues = Record + +export function renderConfigScriptTemplate(template: string, values: ConfigScriptTemplateValues): string { + return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key: string) => { + if (Object.prototype.hasOwnProperty.call(values, key)) { + return values[key] + } + return match + }) +} + +export const WINDOWS_NODE_RUNTIME_NOTICE_TEMPLATE = `where node.exe >nul 2>nul +if errorlevel 1 ( + echo. + echo [LOOK2EYE NOTICE] Windows 未检测到 Node.js;配置文件已写入,但 {{CLIENT_LABEL}} 可能无法安装或运行。 + set "LOOK2EYE_INSTALL_NODE=" + set /p "LOOK2EYE_INSTALL_NODE=是否现在通过 winget 安装 Node.js LTS?[Y/N] " + if /i "!LOOK2EYE_INSTALL_NODE!"=="Y" ( + where winget.exe >nul 2>nul + if errorlevel 1 ( + echo 未检测到 winget,无法自动安装 Node.js。 + echo 请手动下载安装 Node.js LTS:https://nodejs.org/ + ) else ( + winget install --id OpenJS.NodeJS.LTS --source winget --accept-source-agreements --accept-package-agreements + where node.exe >nul 2>nul + if errorlevel 1 ( + echo Node.js 安装后当前窗口仍未检测到 node.exe;请重新打开终端后再运行客户端。 + ) else ( + for /f "delims=" %%I in ('node --version 2^>nul') do echo Node.js: %%I + ) + ) + ) else ( + echo 已跳过 Node.js 安装。请稍后安装 Node.js LTS:https://nodejs.org/ + ) +) else ( + for /f "delims=" %%I in ('node --version 2^>nul') do echo Node.js: %%I + where npm.cmd >nul 2>nul + if errorlevel 1 ( + echo [LOOK2EYE NOTICE] 未检测到 npm;如客户端安装失败,请重新安装 Node.js LTS 并勾选 npm。 + ) else ( + for /f "delims=" %%I in ('npm --version 2^>nul') do echo npm: %%I + ) +)` + +export const WINDOWS_CLIENT_INSTALL_NOTICE_TEMPLATE = `set "LOOK2EYE_CLIENT_FOUND=0" +where {{COMMAND_NAME}} >nul 2>nul +if not errorlevel 1 set "LOOK2EYE_CLIENT_FOUND=1" +if "!LOOK2EYE_CLIENT_FOUND!"=="0" ( + echo. + echo [LOOK2EYE NOTICE] Windows 未检测到 {{CLIENT_LABEL}},准备通过 npm 全局安装。 + where npm.cmd >nul 2>nul + if errorlevel 1 ( + echo 未检测到 npm,无法自动安装 {{CLIENT_LABEL}}。 + echo 请安装 Node.js LTS 后重新运行本脚本,或手动执行:npm install -g {{NPM_PACKAGE}} + ) else ( + npm install -g {{NPM_PACKAGE}} + if errorlevel 1 ( + echo {{CLIENT_LABEL}} 自动安装失败,请手动执行:npm install -g {{NPM_PACKAGE}} + ) else ( + where {{COMMAND_NAME}} >nul 2>nul + if errorlevel 1 ( + echo {{CLIENT_LABEL}} 已安装,但当前窗口仍未检测到 {{COMMAND_NAME}};请重新打开终端后再运行客户端。 + ) else ( + for /f "delims=" %%I in ('{{COMMAND_NAME}} --version 2^>nul') do echo {{CLIENT_LABEL}}: %%I + ) + ) + ) +) else ( + for /f "delims=" %%I in ('{{COMMAND_NAME}} --version 2^>nul') do echo {{CLIENT_LABEL}}: %%I +)` + +export const WINDOWS_EMBEDDED_POWERSHELL_BATCH_TEMPLATE = `@echo off +chcp 65001 >nul +setlocal EnableDelayedExpansion +set "LOOK2EYE_SETUP_SCRIPT_PATH=%~f0" +set "LOOK2EYE_SETUP_PAYLOAD=%TEMP%\\{{TEMP_NAME}}-%RANDOM%-%RANDOM%.ps1" +set "LOOK2EYE_SETUP_EXIT=1" +set "LOOK2EYE_SETUP_MARKER={{MARKER}}" +powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand {{EXTRACTOR_COMMAND}} +if errorlevel 1 ( + set "LOOK2EYE_SETUP_EXIT=1" + goto :look2eye_setup_done +) +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%LOOK2EYE_SETUP_PAYLOAD%"{{FORWARDED_ARGS}} +set "LOOK2EYE_SETUP_EXIT=%ERRORLEVEL%" +if exist "%LOOK2EYE_SETUP_PAYLOAD%" del /f /q "%LOOK2EYE_SETUP_PAYLOAD%" >nul 2>nul +:look2eye_setup_done +echo. +if "%LOOK2EYE_SETUP_EXIT%"=="0" ( + echo ============================================================ + echo [LOOK2EYE SETUP SUCCESS] {{SUCCESS_MESSAGE}} + echo {{SUCCESS_HINT}} + echo ============================================================ +) else ( + echo ============================================================ + echo [LOOK2EYE SETUP FAILED] {{FAILURE_MESSAGE}} Exit code: %LOOK2EYE_SETUP_EXIT% + echo {{FAILURE_HINT}} + echo ============================================================ +) +if "%LOOK2EYE_SETUP_EXIT%"=="0" ( +{{NODE_RUNTIME_NOTICE}} +{{CLIENT_INSTALL_NOTICE}} +) +pause +endlocal & exit /b %LOOK2EYE_SETUP_EXIT% + +{{MARKER}} +{{POWERSHELL_PAYLOAD}}` + +export const WINDOWS_BASIC_BATCH_TEMPLATE = `@echo off +setlocal EnableDelayedExpansion +echo Installing {{SITE_NAME}} {{PAYLOAD_LABEL}} configuration... +powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand {{ENCODED_COMMAND}} +if errorlevel 1 ( + echo Installation failed. + pause + exit /b 1 +) +echo Done. +pause +` diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 087e0e41752..b4696afed11 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -360,6 +360,20 @@ @@ -1138,6 +1183,12 @@ import { buildCcSwitchImportDeeplink, type CcSwitchClientType } from '@/utils/ccswitchImport' +import { + downloadAPIKeyConfigScript, + getConfigScriptOS, + isConfigScriptClientAvailable, + type ConfigScriptClient +} from '@/utils/configScriptDownload' // Helper to format date for datetime-local input const formatDateTimeLocal = (isoDate: string): string => { @@ -1276,11 +1327,15 @@ const pendingCcsRow = ref(null) const selectedKey = ref(null) const copiedKeyId = ref(null) const groupSelectorKeyId = ref(null) +const configScriptMenuKeyId = ref(null) const publicSettings = ref(null) const dropdownRef = ref(null) const columnDropdownRef = ref(null) const dropdownPosition = ref<{ top?: number; bottom?: number; left: number } | null>(null) const groupButtonRefs = ref>(new Map()) +const configScriptMenuRef = ref(null) +const configScriptMenuPosition = ref<{ top: number; left: number } | null>(null) +const configScriptButtonRefs = ref>(new Map()) let abortController: AbortController | null = null // Get the currently selected key for group change @@ -1289,6 +1344,11 @@ const selectedKeyForGroup = computed(() => { return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null }) +const selectedKeyForConfigScript = computed(() => { + if (configScriptMenuKeyId.value === null) return null + return apiKeys.value.find((k) => k.id === configScriptMenuKeyId.value) || null +}) + const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => { if (el instanceof HTMLElement) { groupButtonRefs.value.set(keyId, el) @@ -1297,6 +1357,14 @@ const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance } } +const setConfigScriptButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => { + if (el instanceof HTMLElement) { + configScriptButtonRefs.value.set(keyId, el) + } else { + configScriptButtonRefs.value.delete(keyId) + } +} + const formData = ref({ name: '', group_id: null as number | null, @@ -1362,6 +1430,18 @@ const statusFilterOptions = computed(() => [ { value: 'expired', label: t('keys.status.expired') } ]) +const configScriptClientItems = computed(() => [ + { id: 'codex' as ConfigScriptClient, label: t('keys.configScriptMenu.codexCli') }, + { id: 'claude' as ConfigScriptClient, label: t('keys.configScriptMenu.claudeCode') }, + { id: 'opencode' as ConfigScriptClient, label: t('keys.configScriptMenu.opencode') } +]) + +const configScriptDownloadHint = computed(() => + getConfigScriptOS() === 'win' + ? t('keys.configScriptMenu.hintWindows') + : t('keys.configScriptMenu.hintMac') +) + const onFilterChange = () => { pagination.value.page = 1 loadApiKeys() @@ -1567,6 +1647,7 @@ const toggleKeyStatus = async (key: ApiKey) => { } const openGroupSelector = (key: ApiKey) => { + closeConfigScriptMenu() if (groupSelectorKeyId.value === key.id) { groupSelectorKeyId.value = null dropdownPosition.value = null @@ -1597,6 +1678,79 @@ const openGroupSelector = (key: ApiKey) => { } } +const closeConfigScriptMenu = () => { + configScriptMenuKeyId.value = null + configScriptMenuPosition.value = null +} + +const openConfigScriptMenu = (key: ApiKey) => { + groupSelectorKeyId.value = null + dropdownPosition.value = null + + if (configScriptMenuKeyId.value === key.id) { + closeConfigScriptMenu() + return + } + + const buttonEl = configScriptButtonRefs.value.get(key.id) + if (!buttonEl) return + + const rect = buttonEl.getBoundingClientRect() + const padding = 12 + const menuHeight = 280 + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const menuWidth = Math.max(0, Math.min(450, viewportWidth - padding * 2)) + let left = rect.left + rect.width / 2 - menuWidth / 2 + left = Math.max(padding, Math.min(left, viewportWidth - menuWidth - padding)) + let top = rect.bottom + 8 + if (top + menuHeight > viewportHeight - padding && rect.top > menuHeight) { + top = rect.top - menuHeight - 8 + } + top = Math.max(padding, Math.min(top, viewportHeight - menuHeight - padding)) + + configScriptMenuPosition.value = { top, left } + configScriptMenuKeyId.value = key.id +} + +const isConfigScriptMenuItemAvailable = (client: ConfigScriptClient) => { + const key = selectedKeyForConfigScript.value + if (!key?.group?.platform) return false + return isConfigScriptClientAvailable({ + client, + platform: key.group.platform, + allowMessagesDispatch: key.group.allow_messages_dispatch || false + }) +} + +const downloadConfigScript = (client: ConfigScriptClient) => { + const key = selectedKeyForConfigScript.value + if (!key?.group?.platform) { + appStore.showError(t('keys.useKeyModal.noGroupTitle')) + return + } + if (!isConfigScriptMenuItemAvailable(client)) { + appStore.showError(t('keys.configScriptMenu.unsupportedClient')) + return + } + + try { + downloadAPIKeyConfigScript({ + client, + platform: key.group.platform, + allowMessagesDispatch: key.group.allow_messages_dispatch || false, + baseUrl: publicSettings.value?.api_base_url || window.location.origin, + apiKey: key.key, + siteName: publicSettings.value?.site_name || 'look2eye' + }) + appStore.showSuccess(t('keys.configScriptMenu.downloadStarted')) + closeConfigScriptMenu() + } catch (error) { + console.error('Failed to download config script:', error) + appStore.showError(t('keys.configScriptMenu.downloadFailed')) + } +} + const changeGroup = async (key: ApiKey, newGroupId: number | null) => { groupSelectorKeyId.value = null dropdownPosition.value = null @@ -1613,11 +1767,20 @@ const changeGroup = async (key: ApiKey, newGroupId: number | null) => { const closeGroupSelector = (event: MouseEvent) => { const target = event.target as HTMLElement - // Check if click is inside the dropdown or the trigger button + if (!target.closest('.group\\/dropdown') && !dropdownRef.value?.contains(target)) { groupSelectorKeyId.value = null dropdownPosition.value = null } + + if ( + !target.closest('.config-script-trigger') && + !target.closest('.config-script-menu-content') && + !configScriptMenuRef.value?.contains(target) + ) { + closeConfigScriptMenu() + } + if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) { showColumnDropdown.value = false } diff --git a/tools/archive-pull.sh b/tools/archive-pull.sh new file mode 100755 index 00000000000..5bbfa8b9930 --- /dev/null +++ b/tools/archive-pull.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# archive-pull.sh — 把 VPS 上的请求/响应归档分片增量拉取到本地(外接硬盘)。 +# +# 归档分片一旦关闭即不可变(append-only 的 .jsonl.zst),用 rsync 增量续传安全; +# --remove-source-files 在每个文件成功传完后删除源文件,自动释放 VPS 空间。 +# 文件已是 zstd 压缩,故不加 rsync 的 -z(避免无谓 CPU)。 +# +# 用法: +# archive-pull.sh [YYYY/MM] [bwlimit_kbps] +# +# 示例: +# # 拉取 2026 年 6 月整月到外接硬盘,限速 3 MB/s(与业务共享 50M 带宽时建议限速) +# archive-pull.sh root@vps /app/data/archive /Volumes/HDD/sub2api-archive 2026/06 3000 +# +# # 不限速拉取全部(建议挑业务低谷时段) +# archive-pull.sh root@vps /app/data/archive /Volumes/HDD/sub2api-archive +# +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "用法: $0 [YYYY/MM] [bwlimit_kbps]" >&2 + exit 1 +fi + +REMOTE_HOST="$1" +REMOTE_BASE="${2%/}" +LOCAL_BASE="${3%/}" +SUBPATH="${4:-}" # 例如 2026/06;留空则整个归档目录 +BWLIMIT="${5:-}" # 例如 3000(KB/s);留空则不限速 + +REMOTE_PATH="${REMOTE_BASE}" +LOCAL_PATH="${LOCAL_BASE}" +if [[ -n "${SUBPATH}" ]]; then + REMOTE_PATH="${REMOTE_BASE}/${SUBPATH}" + LOCAL_PATH="${LOCAL_BASE}/${SUBPATH}" +fi + +mkdir -p "${LOCAL_PATH}" + +RSYNC_OPTS=(-av --prune-empty-dirs --remove-source-files --partial --info=progress2) +# 仅传输已关闭的压缩分片,跳过正在写入的临时文件与盐文件。 +RSYNC_OPTS+=(--include='*/' --include='*.jsonl.zst' --exclude='*') +if [[ -n "${BWLIMIT}" ]]; then + RSYNC_OPTS+=(--bwlimit="${BWLIMIT}") +fi + +echo ">> 拉取 ${REMOTE_HOST}:${REMOTE_PATH}/ -> ${LOCAL_PATH}/" +rsync "${RSYNC_OPTS[@]}" "${REMOTE_HOST}:${REMOTE_PATH}/" "${LOCAL_PATH}/" + +# 清理 VPS 上传完后残留的空目录(--remove-source-files 只删文件不删目录)。 +echo ">> 清理远端空目录" +ssh "${REMOTE_HOST}" "find '${REMOTE_PATH}' -type d -empty -delete 2>/dev/null || true" + +echo ">> 完成。本地查看示例:" +echo " zstdcat ${LOCAL_PATH}/.jsonl.zst | jq # 美化浏览" +echo " zstd -d ${LOCAL_PATH}/.jsonl.zst # 解压成明文 .jsonl" +echo " zstdcat ${LOCAL_PATH}/*.jsonl.zst | jq 'select(.model==\"claude-opus-4-8\")'" diff --git a/tools/request-response-archive.md b/tools/request-response-archive.md new file mode 100644 index 00000000000..0f32a66cc12 --- /dev/null +++ b/tools/request-response-archive.md @@ -0,0 +1,69 @@ +# 请求/响应全量归档(Request/Response Archive) + +把每一次网关请求体 + 响应体异步落盘,按天切片、zstd 压缩,便于月末拉到本地长期保存。 +**默认关闭**,需显式开启。 + +## 设计 + +- 全异步、可丢弃:请求侧只把记录塞进有界队列,绝不阻塞业务热路径;队列按字节+条数双限流,溢出丢弃并周期 WARN。 +- 单 writer 协程顺序追加,边写边 zstd 流式压缩,明文从不落盘,CPU 平稳。 +- 响应捕获:网关中间件包裹 `c.Writer`,流式/非流式响应一并捕获(不改动任何 streaming 代码)。 +- 磁盘水位保护:归档分区剩余空间低于阈值则停写,防写爆 DB 所在分区。 + +## 隐私(schema 固定) + +- **存**:请求体、响应体、request_id、时间、内部 ID(user/api_key/account)、inbound 路径、model、stream、status、duration、usage。 +- **客户端 IP**:仅存 `SHA256(salt+IP)`,盐在归档目录 `.ip_salt` 自动生成并持久化(跨重启稳定)。 +- **不存**:上游端点/中转域名、任何密钥、原始 IP。 +- **Header 白名单(默认拒绝,只存白名单)**: + - 请求:`anthropic-version`、`anthropic-beta`、`openai-beta`、`content-type`、`idempotency-key`、`version`、`x-app`、`originator`、`user-agent`、`accept-language`、`x-stainless-*` + - 响应:`request-id`/`x-request-id`/`anthropic-request-id`/`x-amzn-requestid`/`x-goog-request-id`、`retry-after`、`cf-ray`、`cf-mitigated`、`x-codex-*`、`anthropic-ratelimit-unified-*` + +> 注:OpenAI Responses 的 WebSocket 路径不归档(连接被 Hijack,无法捕获响应帧)。 + +## 配置(config.yaml 的 `archive` 段,或等价环境变量) + +| 键 | 默认 | 说明 | +|---|---|---| +| `archive.enabled` | `false` | 总开关 | +| `archive.dir` | `/archive` | 归档目录 | +| `archive.max_shard_size_mb` | `512` | 单分片压缩后大小上限,超过切片 | +| `archive.queue_max_items` | `4096` | 队列最大条数 | +| `archive.queue_max_bytes` | `268435456` | 队列内存预算(256MB),超过丢弃 | +| `archive.max_response_bytes` | `16777216` | 单条响应体捕获上限(16MB),超出截断 | +| `archive.compression_level` | `3` | zstd 级别 1-4(1 最快 / 3 默认 / 4 最高压缩比) | +| `archive.flush_interval_ms` | `1500` | 周期 flush 间隔 | +| `archive.min_free_disk_gb` | `10` | 剩余空间低于此值停写(0=不检查) | +| `archive.ip_hash_salt` | `""` | 留空则自动生成持久盐 | + +开启示例(环境变量,Docker 友好): + +``` +ARCHIVE_ENABLED=true +``` + +## 目录与文件 + +``` +/2026/06/16/reqlog-20260616-000.jsonl.zst +``` + +按天分目录,单文件超阈值再切片(`-000`、`-001` …)。月末按 `2026/06/` 整月选取。 + +## 月末拉取到本地(外接硬盘) + +用仓库内脚本 `tools/archive-pull.sh`(rsync 增量、传完即删源、可限速): + +```bash +# 拉取 2026/06 整月,限速 3MB/s(与业务共享带宽时建议) +tools/archive-pull.sh root@vps /app/data/archive /Volumes/HDD/sub2api-archive 2026/06 3000 +``` + +## 本地查看(无需专用工具) + +```bash +brew install zstd jq # 一次性安装 +zstdcat 2026-06-16-000.jsonl.zst | jq # 边解压边美化浏览 +zstd -d 2026-06-16-000.jsonl.zst # 解压成明文 .jsonl +zstdcat 2026/06/*/*.jsonl.zst | jq 'select(.request_id=="req_abc")' +```