Compare commits
15 Commits
6b19490393
...
feat/kimi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d687715d | ||
|
|
55ca0ad687 | ||
|
|
e746e82c39 | ||
|
|
af3efbc525 | ||
|
|
6449da6c8d | ||
|
|
755c7d5026 | ||
|
|
1da4bd72df | ||
|
|
5551349349 | ||
|
|
c6d25f69d5 | ||
|
|
45065c23d5 | ||
|
|
ddf80f5ea1 | ||
|
|
c048ca80a4 | ||
|
|
22385be515 | ||
|
|
4d0483f5b8 | ||
|
|
1e0d466002 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -126,7 +126,10 @@ backend/cmd/server/server
|
||||
deploy/docker-compose.override.yml
|
||||
.gocache/
|
||||
vite.config.js
|
||||
docs/
|
||||
docs/*
|
||||
!docs/PAYMENT.md
|
||||
!docs/PAYMENT_CN.md
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
.serena/
|
||||
.codex/
|
||||
frontend/coverage/
|
||||
|
||||
12
README.md
12
README.md
@@ -42,18 +42,10 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||
- **Rate Limiting** - Configurable request and token rate limits
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Payment Setup](#payment))
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
||||
- **Admin Dashboard** - Web interface for monitoring and management
|
||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||
|
||||
## Payment
|
||||
|
||||
Sub2API includes the payment system in the main service. No standalone payment service or separate payment guide is required.
|
||||
|
||||
- Supported providers: EasyPay, Alipay, WeChat Pay, Stripe
|
||||
- The frontend keeps user-facing methods unified; admins choose the backing source in `Admin -> Settings -> Payment`
|
||||
- Callback URLs are generated from the site domain when configuring providers
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
> [Want to appear here?](mailto:support@pincc.ai)
|
||||
@@ -117,7 +109,7 @@ Community projects that extend or integrate with Sub2API:
|
||||
|
||||
| Project | Description | Features |
|
||||
|---------|-------------|----------|
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Setup](#payment) |
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Configuration Guide](docs/PAYMENT.md) |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | Mobile admin console | Cross-platform app (iOS/Android/Web) for user management, account management, monitoring dashboard, and multi-backend switching; built with Expo + React Native |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
12
README_CN.md
12
README_CN.md
@@ -41,18 +41,10 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
|
||||
- **智能调度** - 智能账号选择,支持粘性会话
|
||||
- **并发控制** - 用户级和账号级并发限制
|
||||
- **速率限制** - 可配置的请求和 Token 速率限制
|
||||
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([支付说明](#支付))
|
||||
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
|
||||
- **管理后台** - Web 界面进行监控和管理
|
||||
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
|
||||
|
||||
## 支付
|
||||
|
||||
Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也不再依赖单独的支付配置文档。
|
||||
|
||||
- 支持服务商:EasyPay 易支付、支付宝官方、微信官方、Stripe
|
||||
- 前台统一展示用户可见支付方式,管理员在 `管理后台 -> 设置 -> 支付` 里选择对应来源
|
||||
- 添加服务商时会基于站点域名生成回调地址
|
||||
|
||||
## ❤️ 赞助商
|
||||
|
||||
> [想出现在这里?](mailto:support@pincc.ai)
|
||||
@@ -116,7 +108,7 @@ Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也
|
||||
|
||||
| 项目 | 说明 | 功能 |
|
||||
|------|------|------|
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付说明](#支付) |
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
12
README_JA.md
12
README_JA.md
@@ -42,18 +42,10 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([決済案内](#決済))
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
|
||||
## 決済
|
||||
|
||||
Sub2API の決済機能は本体に統合されています。独立した決済サービスや別個の決済ガイドは不要です。
|
||||
|
||||
- 対応プロバイダー: EasyPay、Alipay、WeChat Pay、Stripe
|
||||
- フロントエンドではユーザー向け決済方法を統一表示し、管理者は `管理画面 -> 設定 -> 決済` で実際の接続先を選択します
|
||||
- プロバイダー設定時のコールバック URL はサイトドメインから自動生成されます
|
||||
|
||||
## ❤️ スポンサー
|
||||
|
||||
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
|
||||
@@ -116,7 +108,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
|
||||
|
||||
| プロジェクト | 説明 | 機能 |
|
||||
|---------|-------------|----------|
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済案内](#決済)をご参照ください |
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済設定ガイド](docs/PAYMENT.md)をご参照ください |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 |
|
||||
|
||||
## 技術スタック
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.114
|
||||
0.1.115
|
||||
|
||||
@@ -92,6 +92,7 @@ func provideCleanup(
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
kimiOAuth *service.KimiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
openAIGateway *service.OpenAIGatewayService,
|
||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||
@@ -211,6 +212,10 @@ func provideCleanup(
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"KimiOAuthService", func() error {
|
||||
kimiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AntigravityOAuthService", func() error {
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
|
||||
@@ -121,6 +121,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
driveClient := repository.NewGeminiDriveClient()
|
||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, driveClient, configConfig)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
kimiOAuthService := service.NewKimiOAuthService()
|
||||
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
||||
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
|
||||
timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient)
|
||||
@@ -158,6 +159,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
kimiOAuthHandler := admin.NewKimiOAuthHandler(kimiOAuthService, proxyRepository)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||
promoHandler := admin.NewPromoHandler(promoService)
|
||||
@@ -172,12 +174,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
|
||||
kimiTokenProvider := service.ProvideKimiTokenProvider(accountRepository, geminiTokenCache, kimiOAuthService, oAuthRefreshAPI)
|
||||
digestSessionStore := service.NewDigestSessionStore()
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, kimiTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
@@ -221,7 +224,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, kimiOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
@@ -244,7 +247,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
|
||||
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, kimiOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
|
||||
181
backend/config.test.yaml
Normal file
181
backend/config.test.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 3004
|
||||
mode: "debug"
|
||||
frontend_url: "http://localhost:3003"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "sub2api"
|
||||
password: "sub2api"
|
||||
dbname: "sub2api"
|
||||
sslmode: "disable"
|
||||
max_open_conns: 50
|
||||
max_idle_conns: 10
|
||||
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
pool_size: 100
|
||||
min_idle_conns: 10
|
||||
|
||||
jwt:
|
||||
secret: "test-secret-do-not-use-in-production"
|
||||
expire_hour: 24
|
||||
|
||||
totp:
|
||||
encryption_key: ""
|
||||
|
||||
default:
|
||||
admin_email: "admin@test.com"
|
||||
admin_password: "admin123"
|
||||
user_concurrency: 5
|
||||
user_balance: 0
|
||||
api_key_prefix: "sk-"
|
||||
rate_multiplier: 1.0
|
||||
|
||||
gateway:
|
||||
response_header_timeout: 600
|
||||
max_body_size: 268435456
|
||||
upstream_response_read_max_bytes: 8388608
|
||||
connection_pool_isolation: "account_proxy"
|
||||
max_idle_conns: 2560
|
||||
max_idle_conns_per_host: 120
|
||||
max_conns_per_host: 1024
|
||||
idle_conn_timeout_seconds: 90
|
||||
max_upstream_clients: 5000
|
||||
client_idle_ttl_seconds: 900
|
||||
concurrency_slot_ttl_minutes: 30
|
||||
stream_data_interval_timeout: 180
|
||||
stream_keepalive_interval: 10
|
||||
max_line_size: 41943040
|
||||
log_upstream_error_body: true
|
||||
log_upstream_error_body_max_bytes: 2048
|
||||
inject_beta_for_apikey: false
|
||||
failover_on_400: false
|
||||
scheduling:
|
||||
sticky_session_max_waiting: 3
|
||||
sticky_session_wait_timeout: 120s
|
||||
fallback_wait_timeout: 30s
|
||||
fallback_max_waiting: 100
|
||||
load_batch_enabled: true
|
||||
slot_cleanup_interval: 30s
|
||||
db_fallback_enabled: true
|
||||
db_fallback_timeout_seconds: 0
|
||||
db_fallback_max_qps: 0
|
||||
outbox_poll_interval_seconds: 1
|
||||
outbox_lag_warn_seconds: 5
|
||||
outbox_lag_rebuild_seconds: 10
|
||||
outbox_lag_rebuild_failures: 3
|
||||
outbox_backlog_rebuild_rows: 10000
|
||||
full_rebuild_interval_seconds: 300
|
||||
tls_fingerprint:
|
||||
enabled: false
|
||||
|
||||
log:
|
||||
level: "debug"
|
||||
format: "console"
|
||||
service_name: "sub2api"
|
||||
env: "dev"
|
||||
caller: true
|
||||
stacktrace_level: "error"
|
||||
output:
|
||||
to_stdout: true
|
||||
to_file: false
|
||||
rotation:
|
||||
max_size_mb: 100
|
||||
max_backups: 10
|
||||
max_age_days: 7
|
||||
compress: true
|
||||
local_time: true
|
||||
sampling:
|
||||
enabled: false
|
||||
initial: 100
|
||||
thereafter: 100
|
||||
|
||||
api_key_auth_cache:
|
||||
l1_size: 65535
|
||||
l1_ttl_seconds: 15
|
||||
l2_ttl_seconds: 300
|
||||
negative_ttl_seconds: 30
|
||||
jitter_percent: 10
|
||||
singleflight: true
|
||||
|
||||
dashboard_cache:
|
||||
enabled: true
|
||||
key_prefix: "sub2api:"
|
||||
stats_fresh_ttl_seconds: 15
|
||||
stats_ttl_seconds: 30
|
||||
stats_refresh_timeout_seconds: 30
|
||||
|
||||
dashboard_aggregation:
|
||||
enabled: false
|
||||
interval_seconds: 60
|
||||
lookback_seconds: 120
|
||||
backfill_enabled: false
|
||||
backfill_max_days: 31
|
||||
recompute_days: 2
|
||||
retention:
|
||||
usage_logs_days: 90
|
||||
hourly_days: 180
|
||||
daily_days: 730
|
||||
|
||||
usage_cleanup:
|
||||
enabled: false
|
||||
max_range_days: 31
|
||||
batch_size: 5000
|
||||
worker_interval_seconds: 10
|
||||
task_timeout_seconds: 1800
|
||||
|
||||
idempotency:
|
||||
observe_only: true
|
||||
default_ttl_seconds: 86400
|
||||
system_operation_ttl_seconds: 3600
|
||||
processing_timeout_seconds: 30
|
||||
failed_retry_backoff_seconds: 5
|
||||
max_stored_response_len: 65536
|
||||
cleanup_interval_seconds: 60
|
||||
cleanup_batch_size: 500
|
||||
|
||||
concurrency:
|
||||
ping_interval: 10
|
||||
|
||||
token_refresh:
|
||||
sync_linked_sora_accounts: false
|
||||
|
||||
ops:
|
||||
enabled: false
|
||||
|
||||
rate_limit:
|
||||
overload_cooldown_minutes: 10
|
||||
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false
|
||||
upstream_hosts: []
|
||||
pricing_hosts: []
|
||||
crs_hosts: []
|
||||
allow_private_hosts: true
|
||||
allow_insecure_http: true
|
||||
response_headers:
|
||||
enabled: true
|
||||
additional_allowed: []
|
||||
force_remove: []
|
||||
csp:
|
||||
enabled: false
|
||||
proxy_probe:
|
||||
insecure_skip_verify: false
|
||||
proxy_fallback:
|
||||
allow_direct_on_error: false
|
||||
|
||||
turnstile:
|
||||
required: false
|
||||
|
||||
linuxdo_connect:
|
||||
enabled: false
|
||||
|
||||
oidc_connect:
|
||||
enabled: false
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Wei-Shaw/sub2api
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
entgo.io/ent v0.14.5
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformKimi = "kimi"
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
@@ -31,6 +32,7 @@ const (
|
||||
AccountTypeAPIKey = "apikey" // API Key类型账号
|
||||
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
||||
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
||||
AccountTypeCLI = "cli" // 本地CLI代理类型账号(通过本地安装的CLI工具转发请求)
|
||||
)
|
||||
|
||||
// Redeem type constants
|
||||
|
||||
@@ -560,7 +560,7 @@ func validateDataAccount(item DataAccount) error {
|
||||
return errors.New("account credentials is required")
|
||||
}
|
||||
switch item.Type {
|
||||
case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream:
|
||||
case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream, service.AccountTypeBedrock, service.AccountTypeCLI:
|
||||
default:
|
||||
return fmt.Errorf("account type is invalid: %s", item.Type)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ type CreateAccountRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock"`
|
||||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock cli"`
|
||||
Credentials map[string]any `json:"credentials" binding:"required"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
@@ -117,7 +117,7 @@ type CreateAccountRequest struct {
|
||||
type UpdateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock cli"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
|
||||
@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kimi"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -118,7 +118,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kimi"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
|
||||
182
backend/internal/handler/admin/kimi_oauth_handler.go
Normal file
182
backend/internal/handler/admin/kimi_oauth_handler.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// KimiOAuthHandler handles Kimi OAuth admin endpoints.
|
||||
type KimiOAuthHandler struct {
|
||||
kimiOAuthService *service.KimiOAuthService
|
||||
proxyRepo service.ProxyRepository
|
||||
}
|
||||
|
||||
// NewKimiOAuthHandler creates a new KimiOAuthHandler.
|
||||
func NewKimiOAuthHandler(kimiOAuthService *service.KimiOAuthService, proxyRepo service.ProxyRepository) *KimiOAuthHandler {
|
||||
return &KimiOAuthHandler{
|
||||
kimiOAuthService: kimiOAuthService,
|
||||
proxyRepo: proxyRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type kimiDeviceAuthRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// InitiateDeviceAuth starts the Kimi OAuth device authorization flow.
|
||||
// POST /api/v1/admin/kimi/oauth/device-auth
|
||||
func (h *KimiOAuthHandler) InitiateDeviceAuth(c *gin.Context) {
|
||||
var req kimiDeviceAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.kimiOAuthService.InitiateDeviceAuth(c.Request.Context(), req.ProxyID, h.proxyRepo)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to initiate device auth: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"device_code": result.DeviceCode,
|
||||
"user_code": result.UserCode,
|
||||
"verification_uri": result.VerificationURI,
|
||||
"verification_uri_complete": result.VerificationURIComplete,
|
||||
"expires_in": result.ExpiresIn,
|
||||
"interval": result.Interval,
|
||||
})
|
||||
}
|
||||
|
||||
type kimiPollTokenRequest struct {
|
||||
DeviceCode string `json:"device_code" binding:"required"`
|
||||
}
|
||||
|
||||
// PollToken polls for the OAuth token after device authorization.
|
||||
// POST /api/v1/admin/kimi/oauth/token
|
||||
func (h *KimiOAuthHandler) PollToken(c *gin.Context) {
|
||||
var req kimiPollTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, err := h.kimiOAuthService.PollToken(c.Request.Context(), strings.TrimSpace(req.DeviceCode))
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "authorization_pending") {
|
||||
response.Success(c, gin.H{
|
||||
"status": "pending",
|
||||
"message": "Authorization pending, please complete authorization in the browser",
|
||||
})
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "expired_token") || strings.Contains(msg, "invalid_grant") {
|
||||
response.BadRequest(c, "Token request failed: "+msg)
|
||||
return
|
||||
}
|
||||
response.InternalError(c, "Failed to poll token: "+msg)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"status": "completed",
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"refresh_token": tokenInfo.RefreshToken,
|
||||
"expires_in": tokenInfo.ExpiresIn,
|
||||
"expires_at": tokenInfo.ExpiresAt,
|
||||
"scope": tokenInfo.Scope,
|
||||
"token_type": tokenInfo.TokenType,
|
||||
})
|
||||
}
|
||||
|
||||
type kimiRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// RefreshToken refreshes the Kimi access token.
|
||||
// POST /api/v1/admin/kimi/oauth/refresh
|
||||
func (h *KimiOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req kimiRefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil && h.proxyRepo != nil {
|
||||
if proxy, err := h.proxyRepo.GetByID(c.Request.Context(), *req.ProxyID); err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := h.kimiOAuthService.RefreshToken(c.Request.Context(), strings.TrimSpace(req.RefreshToken), proxyURL)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to refresh token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"refresh_token": tokenInfo.RefreshToken,
|
||||
"expires_in": tokenInfo.ExpiresIn,
|
||||
"expires_at": tokenInfo.ExpiresAt,
|
||||
"scope": tokenInfo.Scope,
|
||||
"token_type": tokenInfo.TokenType,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportLocalToken imports token from local kimi-cli credentials.
|
||||
// POST /api/v1/admin/kimi/oauth/import-local
|
||||
func (h *KimiOAuthHandler) ImportLocalToken(c *gin.Context) {
|
||||
var req struct {
|
||||
CredentialsJSON string `json:"credentials_json" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the local credentials JSON format
|
||||
var localCreds struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt float64 `json:"expires_at"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn float64 `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(req.CredentialsJSON), &localCreds); err != nil {
|
||||
response.BadRequest(c, "Invalid credentials JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(localCreds.AccessToken) == "" {
|
||||
response.BadRequest(c, "access_token is required in credentials")
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := int64(localCreds.ExpiresAt)
|
||||
if expiresAt == 0 && localCreds.ExpiresIn > 0 {
|
||||
expiresAt = time.Now().Unix() + int64(localCreds.ExpiresIn) - service.KimiTokenSafetyWindow
|
||||
}
|
||||
minExpiresAt := time.Now().Unix() + service.KimiTokenMinTTL
|
||||
if expiresAt < minExpiresAt {
|
||||
expiresAt = minExpiresAt
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"access_token": localCreds.AccessToken,
|
||||
"refresh_token": localCreds.RefreshToken,
|
||||
"expires_at": expiresAt,
|
||||
"scope": localCreds.Scope,
|
||||
"token_type": localCreds.TokenType,
|
||||
})
|
||||
}
|
||||
@@ -210,7 +210,12 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
if channelMapping.Mapped {
|
||||
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
|
||||
}
|
||||
result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
|
||||
var result *service.ForwardResult
|
||||
if account.Platform == service.PlatformKimi {
|
||||
result, err = h.gatewayService.ForwardKimiChatCompletions(c.Request.Context(), c, account, forwardBody)
|
||||
} else {
|
||||
result, err = h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
|
||||
}
|
||||
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
|
||||
@@ -17,6 +17,7 @@ type AdminHandlers struct {
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
KimiOAuth *admin.KimiOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
|
||||
@@ -323,6 +323,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
|
||||
emailBinding, ok := identityBindings["email"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, emailBinding["bound"])
|
||||
require.Equal(t, "profile.authBindings.notes.emailManagedFromProfile", emailBinding["note_key"])
|
||||
|
||||
linuxdoCompatBinding, ok := identityBindings["linuxdo"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "profile.authBindings.notes.canUnbind", linuxdoCompatBinding["note_key"])
|
||||
|
||||
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -20,6 +20,7 @@ func ProvideAdminHandlers(
|
||||
openaiOAuthHandler *admin.OpenAIOAuthHandler,
|
||||
geminiOAuthHandler *admin.GeminiOAuthHandler,
|
||||
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
|
||||
kimiOAuthHandler *admin.KimiOAuthHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
promoHandler *admin.PromoHandler,
|
||||
@@ -48,6 +49,7 @@ func ProvideAdminHandlers(
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
KimiOAuth: kimiOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
@@ -142,6 +144,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewOpenAIOAuthHandler,
|
||||
admin.NewGeminiOAuthHandler,
|
||||
admin.NewAntigravityOAuthHandler,
|
||||
admin.NewKimiOAuthHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewPromoHandler,
|
||||
|
||||
@@ -36,11 +36,12 @@ const (
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformKimi = "kimi"
|
||||
)
|
||||
|
||||
// AllPlatforms 返回所有支持的平台列表
|
||||
func AllPlatforms() []string {
|
||||
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity}
|
||||
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformKimi}
|
||||
}
|
||||
|
||||
// Validate 验证规则配置的有效性
|
||||
|
||||
@@ -20,6 +20,8 @@ var DefaultModels = []Model{
|
||||
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
|
||||
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
|
||||
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
|
||||
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
|
||||
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
|
||||
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ func TestAuthIdentityCompatBackfillMigration_AllowsLongReportTypes(t *testing.T)
|
||||
migration108SQL, err := os.ReadFile(migration108Path)
|
||||
require.NoError(t, err)
|
||||
|
||||
migration108aPath := filepath.Join("..", "..", "migrations", "108a_widen_auth_identity_migration_report_type.sql")
|
||||
migration108aSQL, err := os.ReadFile(migration108aPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
migration109Path := filepath.Join("..", "..", "migrations", "109_auth_identity_compat_backfill.sql")
|
||||
migration109SQL, err := os.ReadFile(migration109Path)
|
||||
require.NoError(t, err)
|
||||
@@ -41,6 +45,9 @@ ALTER TABLE users
|
||||
_, err = tx.ExecContext(ctx, string(migration108SQL))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tx.ExecContext(ctx, string(migration108aSQL))
|
||||
require.NoError(t, err)
|
||||
|
||||
var userID int64
|
||||
require.NoError(t, tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (email, password_hash, role, status, balance, concurrency)
|
||||
|
||||
@@ -77,6 +77,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
@@ -114,6 +115,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
@@ -151,6 +153,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
|
||||
@@ -41,6 +41,9 @@ func RegisterAdminRoutes(
|
||||
// Antigravity OAuth
|
||||
registerAntigravityOAuthRoutes(admin, h)
|
||||
|
||||
// Kimi OAuth
|
||||
registerKimiOAuthRoutes(admin, h)
|
||||
|
||||
// 代理管理
|
||||
registerProxyRoutes(admin, h)
|
||||
|
||||
@@ -338,6 +341,16 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
||||
}
|
||||
}
|
||||
|
||||
func registerKimiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
kimi := admin.Group("/kimi")
|
||||
{
|
||||
kimi.POST("/oauth/device-auth", h.Admin.KimiOAuth.InitiateDeviceAuth)
|
||||
kimi.POST("/oauth/token", h.Admin.KimiOAuth.PollToken)
|
||||
kimi.POST("/oauth/refresh", h.Admin.KimiOAuth.RefreshToken)
|
||||
kimi.POST("/oauth/import-local", h.Admin.KimiOAuth.ImportLocalToken)
|
||||
}
|
||||
}
|
||||
|
||||
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
proxies := admin.Group("/proxies")
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -52,8 +53,14 @@ type TestEvent struct {
|
||||
const (
|
||||
defaultGeminiTextTestPrompt = "hi"
|
||||
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||
)
|
||||
|
||||
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
|
||||
func isOpenAIImageModel(model string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(model), "gpt-image-")
|
||||
}
|
||||
|
||||
// AccountTestService handles account testing operations
|
||||
type AccountTestService struct {
|
||||
accountRepo AccountRepository
|
||||
@@ -430,6 +437,18 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
}
|
||||
}
|
||||
|
||||
// Route to image generation test if an image model is selected
|
||||
if isOpenAIImageModel(testModelID) {
|
||||
imagePrompt := strings.TrimSpace(prompt)
|
||||
if imagePrompt == "" {
|
||||
imagePrompt = defaultOpenAIImageTestPrompt
|
||||
}
|
||||
if account.Type == "apikey" {
|
||||
return s.testOpenAIImageAPIKey(c, ctx, account, testModelID, imagePrompt)
|
||||
}
|
||||
return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt)
|
||||
}
|
||||
|
||||
// Determine authentication method and API URL
|
||||
var authToken string
|
||||
var apiURL string
|
||||
@@ -1026,7 +1045,336 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
|
||||
}
|
||||
}
|
||||
|
||||
// sendEvent sends a SSE event to the client
|
||||
// testOpenAIImageAPIKey tests OpenAI image generation using an API Key account.
|
||||
func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||
authToken := account.GetOpenAIApiKey()
|
||||
if authToken == "" {
|
||||
return s.sendErrorAndEnd(c, "No API key available")
|
||||
}
|
||||
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
|
||||
}
|
||||
apiURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/v1/images/generations"
|
||||
|
||||
// Set SSE headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||
|
||||
payload := map[string]any{
|
||||
"model": modelID,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read response: %s", err.Error()))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
||||
}
|
||||
|
||||
// Parse {"data": [{"b64_json": "...", "revised_prompt": "..."}]}
|
||||
var result struct {
|
||||
Data []struct {
|
||||
B64JSON string `json:"b64_json"`
|
||||
RevisedPrompt string `json:"revised_prompt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error()))
|
||||
}
|
||||
|
||||
if len(result.Data) == 0 {
|
||||
return s.sendErrorAndEnd(c, "No images returned from API")
|
||||
}
|
||||
|
||||
for _, item := range result.Data {
|
||||
if item.RevisedPrompt != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt})
|
||||
}
|
||||
if item.B64JSON != "" {
|
||||
s.sendEvent(c, TestEvent{
|
||||
Type: "image",
|
||||
ImageURL: "data:image/png;base64," + item.B64JSON,
|
||||
MimeType: "image/png",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via ChatGPT backend API.
|
||||
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||
authToken := account.GetOpenAIAccessToken()
|
||||
if authToken == "" {
|
||||
return s.sendErrorAndEnd(c, "No access token available")
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Initializing ChatGPT backend...\n"})
|
||||
|
||||
// Build headers (replicating buildOpenAIBackendAPIHeaders logic)
|
||||
headers := buildOpenAIBackendAPIHeadersForTest(ctx, account, authToken, s.accountRepo)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
client, err := newOpenAIBackendAPIClient(proxyURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create client: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Bootstrap
|
||||
if bootstrapErr := bootstrapOpenAIBackendAPI(ctx, client, headers); bootstrapErr != nil {
|
||||
log.Printf("OpenAI image test bootstrap warning: %v", bootstrapErr)
|
||||
}
|
||||
|
||||
// Fetch chat requirements
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Fetching chat requirements...\n"})
|
||||
chatReqs, err := fetchOpenAIChatRequirements(ctx, client, headers)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Chat requirements failed: %s", err.Error()))
|
||||
}
|
||||
if chatReqs.Arkose.Required {
|
||||
return s.sendErrorAndEnd(c, "Unsupported challenge: arkose required")
|
||||
}
|
||||
|
||||
// Initialize and prepare conversation
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Preparing image conversation...\n"})
|
||||
parentMessageID := uuid.NewString()
|
||||
proofToken := generateOpenAIProofToken(chatReqs.ProofOfWork.Required, chatReqs.ProofOfWork.Seed, chatReqs.ProofOfWork.Difficulty, headers.Get("User-Agent"))
|
||||
_ = initializeOpenAIImageConversation(ctx, client, headers)
|
||||
conduitToken, err := prepareOpenAIImageConversation(ctx, client, headers, prompt, parentMessageID, chatReqs.Token, proofToken)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation prepare failed: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Build simplified conversation request (no file uploads)
|
||||
convReq := buildOpenAIImageTestConversationRequest(prompt, parentMessageID)
|
||||
convHeaders := cloneHTTPHeader(headers)
|
||||
convHeaders.Set("Accept", "text/event-stream")
|
||||
convHeaders.Set("Content-Type", "application/json")
|
||||
convHeaders.Set("openai-sentinel-chat-requirements-token", chatReqs.Token)
|
||||
if conduitToken != "" {
|
||||
convHeaders.Set("x-conduit-token", conduitToken)
|
||||
}
|
||||
if proofToken != "" {
|
||||
convHeaders.Set("openai-sentinel-proof-token", proofToken)
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Generating image...\n"})
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
DisableAutoReadResponse().
|
||||
SetHeaders(headerToMap(convHeaders)).
|
||||
SetBodyJsonMarshal(convReq).
|
||||
Post(openAIChatGPTConversationURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
if resp.StatusCode >= 400 {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation API returned %d", resp.StatusCode))
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
conversationID, pointerInfos, _, _, err := readOpenAIImageConversationStream(resp, startTime)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read failed: %s", err.Error()))
|
||||
}
|
||||
|
||||
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, nil)
|
||||
if conversationID != "" && !hasOpenAIFileServicePointerInfos(pointerInfos) {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Waiting for image generation to complete...\n"})
|
||||
polledPointers, pollErr := pollOpenAIImageConversation(ctx, client, headers, conversationID)
|
||||
if pollErr != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Poll failed: %s", pollErr.Error()))
|
||||
}
|
||||
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, polledPointers)
|
||||
}
|
||||
pointerInfos = preferOpenAIFileServicePointerInfos(pointerInfos)
|
||||
if len(pointerInfos) == 0 {
|
||||
return s.sendErrorAndEnd(c, "No images returned from conversation")
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Downloading generated image...\n"})
|
||||
|
||||
// Download and encode each image
|
||||
for _, pointer := range pointerInfos {
|
||||
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Download URL fetch failed: %s", err.Error()))
|
||||
}
|
||||
data, err := downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Image download failed: %s", err.Error()))
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
mimeType := http.DetectContentType(data)
|
||||
if pointer.Prompt != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: pointer.Prompt})
|
||||
}
|
||||
s.sendEvent(c, TestEvent{
|
||||
Type: "image",
|
||||
ImageURL: "data:" + mimeType + ";base64," + b64,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOpenAIBackendAPIHeadersForTest builds ChatGPT backend API headers for test purposes.
|
||||
// Replicates the logic from OpenAIGatewayService.buildOpenAIBackendAPIHeaders without
|
||||
// requiring the full gateway service dependency.
|
||||
func buildOpenAIBackendAPIHeadersForTest(ctx context.Context, account *Account, token string, repo AccountRepository) http.Header {
|
||||
// Ensure device and session IDs exist
|
||||
deviceID := account.GetOpenAIDeviceID()
|
||||
sessionID := account.GetOpenAISessionID()
|
||||
if deviceID == "" || sessionID == "" {
|
||||
updates := map[string]any{}
|
||||
if deviceID == "" {
|
||||
deviceID = uuid.NewString()
|
||||
updates["openai_device_id"] = deviceID
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.NewString()
|
||||
updates["openai_session_id"] = sessionID
|
||||
}
|
||||
if account.Extra == nil {
|
||||
account.Extra = map[string]any{}
|
||||
}
|
||||
for key, value := range updates {
|
||||
account.Extra[key] = value
|
||||
}
|
||||
if repo != nil {
|
||||
updateCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
_ = repo.UpdateExtra(updateCtx, account.ID, updates)
|
||||
}
|
||||
}
|
||||
|
||||
headers := make(http.Header)
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
headers.Set("Accept", "application/json")
|
||||
headers.Set("Origin", "https://chatgpt.com")
|
||||
headers.Set("Referer", "https://chatgpt.com/")
|
||||
headers.Set("Sec-Fetch-Dest", "empty")
|
||||
headers.Set("Sec-Fetch-Mode", "cors")
|
||||
headers.Set("Sec-Fetch-Site", "same-origin")
|
||||
headers.Set("User-Agent", openAIImageBackendUserAgent)
|
||||
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
|
||||
headers.Set("User-Agent", customUA)
|
||||
}
|
||||
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
|
||||
headers.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
if deviceID != "" {
|
||||
headers.Set("oai-device-id", deviceID)
|
||||
headers.Set("Cookie", "oai-did="+deviceID)
|
||||
}
|
||||
if sessionID != "" {
|
||||
headers.Set("oai-session-id", sessionID)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// buildOpenAIImageTestConversationRequest creates a simplified image generation conversation request.
|
||||
func buildOpenAIImageTestConversationRequest(prompt, parentMessageID string) map[string]any {
|
||||
promptText := strings.TrimSpace(prompt)
|
||||
if promptText == "" {
|
||||
promptText = "Generate an image."
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"developer_mode_connector_ids": []any{},
|
||||
"selected_github_repos": []any{},
|
||||
"selected_all_github_repos": false,
|
||||
"system_hints": []string{"picture_v2"},
|
||||
"serialization_metadata": map[string]any{
|
||||
"custom_symbol_offsets": []any{},
|
||||
},
|
||||
}
|
||||
message := map[string]any{
|
||||
"id": uuid.NewString(),
|
||||
"author": map[string]any{"role": "user"},
|
||||
"content": map[string]any{
|
||||
"content_type": "text",
|
||||
"parts": []any{promptText},
|
||||
},
|
||||
"metadata": metadata,
|
||||
"create_time": float64(time.Now().UnixMilli()) / 1000,
|
||||
}
|
||||
return map[string]any{
|
||||
"action": "next",
|
||||
"client_prepare_state": "sent",
|
||||
"parent_message_id": parentMessageID,
|
||||
"messages": []any{message},
|
||||
"model": "auto",
|
||||
"timezone_offset_min": openAITimezoneOffsetMinutes(),
|
||||
"timezone": openAITimezoneName(),
|
||||
"conversation_mode": map[string]any{"kind": "primary_assistant"},
|
||||
"system_hints": []string{"picture_v2"},
|
||||
"supports_buffering": true,
|
||||
"supported_encodings": []string{"v1"},
|
||||
"client_contextual_info": map[string]any{"app_name": "chatgpt.com"},
|
||||
"force_nulligen": false,
|
||||
"force_paragen": false,
|
||||
"force_paragen_model_slug": "",
|
||||
"force_rate_limit": false,
|
||||
"websocket_request_id": uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
PlatformOpenAI = domain.PlatformOpenAI
|
||||
PlatformGemini = domain.PlatformGemini
|
||||
PlatformAntigravity = domain.PlatformAntigravity
|
||||
PlatformKimi = domain.PlatformKimi
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
@@ -33,6 +34,7 @@ const (
|
||||
AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
|
||||
AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
||||
AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
||||
AccountTypeCLI = domain.AccountTypeCLI // 本地CLI代理类型账号(通过本地安装的CLI工具转发请求)
|
||||
)
|
||||
|
||||
// Redeem type constants
|
||||
|
||||
@@ -547,6 +547,7 @@ type GatewayService struct {
|
||||
deferredService *DeferredService
|
||||
concurrencyService *ConcurrencyService
|
||||
claudeTokenProvider *ClaudeTokenProvider
|
||||
kimiTokenProvider *KimiTokenProvider
|
||||
sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
|
||||
rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken)
|
||||
userGroupRateResolver *userGroupRateResolver
|
||||
@@ -585,6 +586,7 @@ func NewGatewayService(
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
claudeTokenProvider *ClaudeTokenProvider,
|
||||
kimiTokenProvider *KimiTokenProvider,
|
||||
sessionLimitCache SessionLimitCache,
|
||||
rpmCache RPMCache,
|
||||
digestStore *DigestSessionStore,
|
||||
@@ -617,6 +619,7 @@ func NewGatewayService(
|
||||
httpUpstream: httpUpstream,
|
||||
deferredService: deferredService,
|
||||
claudeTokenProvider: claudeTokenProvider,
|
||||
kimiTokenProvider: kimiTokenProvider,
|
||||
sessionLimitCache: sessionLimitCache,
|
||||
rpmCache: rpmCache,
|
||||
userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
|
||||
@@ -3451,6 +3454,8 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (
|
||||
return apiKey, "apikey", nil
|
||||
case AccountTypeBedrock:
|
||||
return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
|
||||
case AccountTypeCLI:
|
||||
return "", "cli", nil // CLI 类型由 CLI gateway 自行管理认证
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
@@ -3466,6 +3471,15 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s
|
||||
return accessToken, "oauth", nil
|
||||
}
|
||||
|
||||
// 对于 Kimi OAuth 账号,使用 KimiTokenProvider 获取缓存的 token
|
||||
if account.Platform == PlatformKimi && account.Type == AccountTypeOAuth && s.kimiTokenProvider != nil {
|
||||
accessToken, err := s.kimiTokenProvider.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return accessToken, "oauth", nil
|
||||
}
|
||||
|
||||
// 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取
|
||||
accessToken := account.GetCredential("access_token")
|
||||
if accessToken == "" {
|
||||
|
||||
446
backend/internal/service/kimi_cli_gateway.go
Normal file
446
backend/internal/service/kimi_cli_gateway.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
kimiCLICommand = "kimi"
|
||||
kimiCLITimeout = 10 * time.Minute
|
||||
kimiCLIStreamJSONFlag = "stream-json"
|
||||
)
|
||||
|
||||
// KimiCLIGateway handles forwarding chat completions requests through the local kimi-cli.
|
||||
type KimiCLIGateway struct {
|
||||
// cliPath is the resolved path to the kimi binary. If empty, "kimi" is used.
|
||||
cliPath string
|
||||
}
|
||||
|
||||
// NewKimiCLIGateway creates a new CLI gateway, attempting to resolve the kimi binary.
|
||||
func NewKimiCLIGateway() *KimiCLIGateway {
|
||||
path := resolveKimiCLI()
|
||||
return &KimiCLIGateway{cliPath: path}
|
||||
}
|
||||
|
||||
// IsAvailable returns true if the kimi CLI is found on the system.
|
||||
func (g *KimiCLIGateway) IsAvailable() bool {
|
||||
return g.cliPath != ""
|
||||
}
|
||||
|
||||
// ForwardChatCompletions forwards an OpenAI Chat Completions request through kimi-cli.
|
||||
func (g *KimiCLIGateway) ForwardChatCompletions(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
if !g.IsAvailable() {
|
||||
return nil, errors.New("kimi CLI not found. Please install it: uv tool install --python 3.13 kimi-cli")
|
||||
}
|
||||
|
||||
// 1. Parse request
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
originalModel := gjson.GetBytes(body, "model").String()
|
||||
mappedModel := account.GetMappedModel(originalModel)
|
||||
if mappedModel == "" {
|
||||
mappedModel = originalModel
|
||||
}
|
||||
|
||||
// 2. Build NDJSON messages for stdin
|
||||
ndjsonInput, err := buildKimiNDJSONMessages(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build messages: %w", err)
|
||||
}
|
||||
|
||||
// 3. Build CLI args
|
||||
// Note: kimi-cli does not support --model; it uses the default model.
|
||||
// The API only exposes K2.6, but the actual model is determined by the CLI.
|
||||
args := []string{
|
||||
"--print",
|
||||
"--output-format", kimiCLIStreamJSONFlag,
|
||||
"--input-format", "stream-json",
|
||||
}
|
||||
|
||||
// 4. Run CLI (CLI manages its own OAuth auth via `kimi login`)
|
||||
// Use a temp working directory to prevent kimi-cli from auto-resuming
|
||||
// the last session associated with the backend's working directory.
|
||||
// This ensures each request is stateless and context-isolated.
|
||||
cmd := exec.CommandContext(ctx, g.cliPath, args...)
|
||||
cmd.Dir = "/tmp"
|
||||
cmd.Stdin = strings.NewReader(ndjsonInput)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start cli: %w", err)
|
||||
}
|
||||
|
||||
// Drain stderr in background for debugging
|
||||
go func() {
|
||||
slurp, _ := io.ReadAll(stderr)
|
||||
if len(slurp) > 0 {
|
||||
logger.LegacyPrintf("service.kimi_cli", "stderr: %s", string(slurp))
|
||||
}
|
||||
}()
|
||||
|
||||
// 5. Parse output and forward
|
||||
var result *ForwardResult
|
||||
if reqStream {
|
||||
result, err = g.handleStreamingResponse(stdout, c, originalModel, mappedModel, startTime)
|
||||
} else {
|
||||
result, err = g.handleNonStreamingResponse(stdout, c, originalModel, mappedModel, startTime)
|
||||
}
|
||||
|
||||
// Wait for process to finish
|
||||
if waitErr := cmd.Wait(); waitErr != nil && result == nil {
|
||||
return nil, fmt.Errorf("cli exited: %w", waitErr)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// handleStreamingResponse reads the single NDJSON line from kimi-cli and sends it as SSE.
|
||||
func (g *KimiCLIGateway) handleStreamingResponse(
|
||||
stdout io.Reader,
|
||||
c *gin.Context,
|
||||
originalModel, mappedModel string,
|
||||
startTime time.Time,
|
||||
) (*ForwardResult, error) {
|
||||
if c == nil || c.Writer == nil {
|
||||
return nil, errors.New("gin context or writer is nil")
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, errors.New("streaming not supported")
|
||||
}
|
||||
|
||||
msg, err := parseKimiCLIMessage(stdout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cli output: %w", err)
|
||||
}
|
||||
|
||||
var firstTokenTime *time.Duration
|
||||
chatID := generateChatCompletionID()
|
||||
created := time.Now().Unix()
|
||||
|
||||
// Extract text content
|
||||
text := extractKimiTextContent(msg)
|
||||
thinking := extractKimiThinkingContent(msg)
|
||||
|
||||
// Build full content (prepend thinking if present)
|
||||
fullContent := text
|
||||
if thinking != "" {
|
||||
fullContent = fmt.Sprintf("<think>\n%s\n</think>\n\n%s", thinking, text)
|
||||
}
|
||||
|
||||
// Send role delta
|
||||
if firstTokenTime == nil {
|
||||
elapsed := time.Since(startTime)
|
||||
firstTokenTime = &elapsed
|
||||
}
|
||||
chunk := buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{Role: "assistant"}, nil)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
|
||||
flusher.Flush()
|
||||
|
||||
// Send content delta (simplified: send all at once since CLI gives full response)
|
||||
chunk = buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{Content: fullContent}, nil)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
|
||||
flusher.Flush()
|
||||
|
||||
// Send finish
|
||||
chunk = buildSSEChunk(chatID, created, mappedModel, 0, &kimiDelta{}, stringPtr("stop"))
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
|
||||
flusher.Flush()
|
||||
|
||||
// [DONE]
|
||||
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
var firstTokenMs *int
|
||||
if firstTokenTime != nil {
|
||||
ms := int(firstTokenTime.Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
UpstreamModel: mappedModel,
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleNonStreamingResponse reads the single NDJSON line and returns a complete OpenAI response.
|
||||
func (g *KimiCLIGateway) handleNonStreamingResponse(
|
||||
stdout io.Reader,
|
||||
c *gin.Context,
|
||||
originalModel, mappedModel string,
|
||||
startTime time.Time,
|
||||
) (*ForwardResult, error) {
|
||||
msg, err := parseKimiCLIMessage(stdout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cli output: %w", err)
|
||||
}
|
||||
|
||||
text := extractKimiTextContent(msg)
|
||||
thinking := extractKimiThinkingContent(msg)
|
||||
|
||||
fullContent := text
|
||||
if thinking != "" {
|
||||
fullContent = fmt.Sprintf("<think>\n%s\n</think>\n\n%s", thinking, text)
|
||||
}
|
||||
|
||||
resp := kimiChatCompletionResponse{
|
||||
ID: generateChatCompletionID(),
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: mappedModel,
|
||||
Choices: []kimiChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: kimiMessage{
|
||||
Role: "assistant",
|
||||
Content: fullContent,
|
||||
},
|
||||
FinishReason: stringPtr("stop"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
respBytes, _ := json.Marshal(resp)
|
||||
|
||||
if c != nil && c.Writer != nil {
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(respBytes)
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
UpstreamModel: mappedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// resolveKimiCLI attempts to find the kimi binary in PATH.
|
||||
func resolveKimiCLI() string {
|
||||
if path, err := exec.LookPath(kimiCLICommand); err == nil && path != "" {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildKimiNDJSONMessages converts an OpenAI Chat Completions request body into
|
||||
// NDJSON lines suitable for kimi-cli --input-format stream-json.
|
||||
func buildKimiNDJSONMessages(body []byte) (string, error) {
|
||||
messagesResult := gjson.GetBytes(body, "messages")
|
||||
if !messagesResult.Exists() || !messagesResult.IsArray() {
|
||||
return "", errors.New("missing or invalid messages array")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
messagesResult.ForEach(func(_, msg gjson.Result) bool {
|
||||
role := msg.Get("role").String()
|
||||
content := msg.Get("content").String()
|
||||
|
||||
// Handle array content (e.g. vision messages with multiple parts)
|
||||
if content == "" && msg.Get("content").IsArray() {
|
||||
var texts []string
|
||||
msg.Get("content").ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "text" {
|
||||
texts = append(texts, item.Get("text").String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
content = strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
if role == "" || content == "" {
|
||||
return true // skip empty
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(`{"role":%q,"content":%q}`, role, content)
|
||||
lines = append(lines, line)
|
||||
return true
|
||||
})
|
||||
|
||||
if len(lines) == 0 {
|
||||
return "", errors.New("no valid messages found")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
|
||||
// parseKimiCLIMessage reads all NDJSON lines from stdout and returns the last valid one.
|
||||
// When multiple messages are piped via stdin, kimi-cli outputs one response per turn;
|
||||
// we only need the last one (the response to the final user message).
|
||||
func parseKimiCLIMessage(stdout io.Reader) (map[string]interface{}, error) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 4096), 1024*1024)
|
||||
|
||||
var lastMsg map[string]interface{}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Skip the "To resume this session" line
|
||||
if strings.HasPrefix(line, "To resume this session") {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
continue // skip non-JSON lines
|
||||
}
|
||||
lastMsg = msg
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan cli output: %w", err)
|
||||
}
|
||||
|
||||
if lastMsg == nil {
|
||||
return nil, errors.New("no valid JSON output from kimi-cli")
|
||||
}
|
||||
return lastMsg, nil
|
||||
}
|
||||
|
||||
// extractKimiTextContent extracts text from a kimi-cli message.
|
||||
func extractKimiTextContent(msg map[string]interface{}) string {
|
||||
content, ok := msg["content"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// String content
|
||||
if s, ok := content.(string); ok {
|
||||
return s
|
||||
}
|
||||
|
||||
// Array content
|
||||
arr, ok := content.([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var texts []string
|
||||
for _, item := range arr {
|
||||
block, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if block["type"] == "text" {
|
||||
if t, ok := block["text"].(string); ok {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
// extractKimiThinkingContent extracts thinking content from a kimi-cli message.
|
||||
func extractKimiThinkingContent(msg map[string]interface{}) string {
|
||||
content, ok := msg["content"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
arr, ok := content.([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var thinks []string
|
||||
for _, item := range arr {
|
||||
block, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if block["type"] == "think" {
|
||||
if t, ok := block["think"].(string); ok {
|
||||
thinks = append(thinks, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(thinks, "\n")
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible response structs ---
|
||||
|
||||
type kimiDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type kimiChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message kimiMessage `json:"message,omitempty"`
|
||||
Delta *kimiDelta `json:"delta,omitempty"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type kimiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type kimiChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []kimiChoice `json:"choices"`
|
||||
}
|
||||
|
||||
func buildSSEChunk(id string, created int64, model string, index int, delta *kimiDelta, finishReason *string) string {
|
||||
chunk := kimiChatCompletionResponse{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []kimiChoice{
|
||||
{
|
||||
Index: index,
|
||||
Delta: delta,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(chunk)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func generateChatCompletionID() string {
|
||||
return fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
258
backend/internal/service/kimi_gateway_service.go
Normal file
258
backend/internal/service/kimi_gateway_service.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
kimiAPIBaseURL = "https://api.kimi.com/coding/v1"
|
||||
kimiChatCompletionsURL = kimiAPIBaseURL + "/chat/completions"
|
||||
kimiDefaultUserAgent = "kimi-cli/1.0"
|
||||
)
|
||||
|
||||
// ForwardKimiChatCompletions forwards an OpenAI Chat Completions request directly to Kimi API.
|
||||
// Kimi API is OpenAI Chat Completions compatible, so the body is forwarded as-is.
|
||||
// For CLI-type accounts, requests are forwarded through the local kimi-cli binary.
|
||||
func (s *GatewayService) ForwardKimiChatCompletions(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
) (*ForwardResult, error) {
|
||||
// If account type is CLI, use local kimi-cli (CLI manages its own auth)
|
||||
if account.Type == AccountTypeCLI {
|
||||
cliGateway := NewKimiCLIGateway()
|
||||
return cliGateway.ForwardChatCompletions(ctx, c, account, body)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 1. Get access token
|
||||
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
// 2. Get proxy URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 3. Parse request to determine if streaming
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
// 4. Build upstream request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", kimiChatCompletionsURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
|
||||
// Set auth header
|
||||
if tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
setHeaderRaw(req.Header, "x-api-key", token)
|
||||
}
|
||||
|
||||
// Set required headers for Kimi API
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
setHeaderRaw(req.Header, "accept", "application/json")
|
||||
// Kimi Coding API requires a known coding agent User-Agent pattern
|
||||
setHeaderRaw(req.Header, "user-agent", kimiDefaultUserAgent)
|
||||
|
||||
// Passthrough allowed client headers
|
||||
var clientHeaders http.Header
|
||||
if c != nil && c.Request != nil {
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
for key, values := range clientHeaders {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if allowedHeaders[lowerKey] {
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resolve TLS fingerprint profile
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 6. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
Kind: "request_error",
|
||||
Message: safeErr,
|
||||
})
|
||||
writeGatewayCCError(c, http.StatusBadGateway, "server_error", "Upstream request failed")
|
||||
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 7. Handle error response
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
|
||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
})
|
||||
if s.rateLimitService != nil {
|
||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
}
|
||||
}
|
||||
|
||||
writeGatewayCCError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg)
|
||||
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// 8. Handle successful response
|
||||
originalModel := gjson.GetBytes(body, "model").String()
|
||||
mappedModel := originalModel
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
mappedModel = account.GetMappedModel(originalModel)
|
||||
}
|
||||
|
||||
var result *ForwardResult
|
||||
if reqStream {
|
||||
result, err = s.handleKimiStreamingResponse(resp, c, originalModel, mappedModel, startTime)
|
||||
} else {
|
||||
result, err = s.handleKimiNonStreamingResponse(resp, c, originalModel, mappedModel, startTime)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleKimiStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
originalModel, mappedModel string,
|
||||
startTime time.Time,
|
||||
) (*ForwardResult, error) {
|
||||
if c == nil || c.Writer == nil {
|
||||
return nil, errors.New("gin context or writer is nil")
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, errors.New("streaming not supported")
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 4096), defaultMaxLineSize)
|
||||
|
||||
var firstTokenTime *time.Duration
|
||||
var upstreamModel string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect first token
|
||||
if firstTokenTime == nil {
|
||||
elapsed := time.Since(startTime)
|
||||
firstTokenTime = &elapsed
|
||||
}
|
||||
|
||||
// Write the SSE line as-is
|
||||
fmt.Fprintf(c.Writer, "%s\n", line) //nolint:errcheck
|
||||
flusher.Flush()
|
||||
|
||||
// Try to extract upstream model from the first data line
|
||||
if upstreamModel == "" && strings.HasPrefix(line, "data: {") {
|
||||
upstreamModel = gjson.Get(line[6:], "model").String()
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
|
||||
logger.LegacyPrintf("service.gateway", "Kimi stream scanner error: %v", err)
|
||||
}
|
||||
|
||||
// Send final [DONE] if not already sent by upstream
|
||||
fmt.Fprintf(c.Writer, "data: [DONE]\n\n") //nolint:errcheck
|
||||
flusher.Flush()
|
||||
|
||||
var firstTokenMs *int
|
||||
if firstTokenTime != nil {
|
||||
ms := int(firstTokenTime.Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
UpstreamModel: upstreamModel,
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleKimiNonStreamingResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
originalModel, mappedModel string,
|
||||
startTime time.Time,
|
||||
) (*ForwardResult, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read upstream response: %w", err)
|
||||
}
|
||||
|
||||
if c != nil && c.Writer != nil {
|
||||
// Copy upstream headers
|
||||
for key, values := range resp.Header {
|
||||
for _, v := range values {
|
||||
c.Writer.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, _ = c.Writer.Write(body)
|
||||
}
|
||||
|
||||
upstreamModel := gjson.GetBytes(body, "model").String()
|
||||
|
||||
return &ForwardResult{
|
||||
UpstreamModel: upstreamModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
306
backend/internal/service/kimi_oauth_service.go
Normal file
306
backend/internal/service/kimi_oauth_service.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
kimiAuthBaseURL = "https://auth.kimi.com"
|
||||
kimiDeviceAuthURL = kimiAuthBaseURL + "/api/oauth/device_authorization"
|
||||
kimiTokenURL = kimiAuthBaseURL + "/api/oauth/token"
|
||||
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||||
kimiDefaultScope = "kimi-code"
|
||||
KimiTokenSafetyWindow = 300 // 5 minutes
|
||||
KimiTokenMinTTL = 30 // 30 seconds
|
||||
)
|
||||
|
||||
// KimiDeviceAuthResponse represents the response from the device authorization endpoint.
|
||||
type KimiDeviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Interval int64 `json:"interval"`
|
||||
}
|
||||
|
||||
// KimiTokenResponse represents the response from the token endpoint.
|
||||
type KimiTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// KimiTokenInfo holds token information for an account.
|
||||
type KimiTokenInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
}
|
||||
|
||||
// KimiOAuthService handles Kimi OAuth device flow and token refresh.
|
||||
type KimiOAuthService struct {
|
||||
sessions sync.Map // device_code -> *kimiOAuthSession
|
||||
}
|
||||
|
||||
type kimiOAuthSession struct {
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
VerificationURI string
|
||||
VerificationURIComplete string
|
||||
ExpiresAt time.Time
|
||||
Interval time.Duration
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
// NewKimiOAuthService creates a new KimiOAuthService.
|
||||
func NewKimiOAuthService() *KimiOAuthService {
|
||||
return &KimiOAuthService{}
|
||||
}
|
||||
|
||||
// InitiateDeviceAuth starts the OAuth 2.0 Device Authorization Grant flow.
|
||||
func (s *KimiOAuthService) InitiateDeviceAuth(ctx context.Context, proxyID *int64, proxyRepo ProxyRepository) (*KimiDeviceAuthResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
data.Set("scope", kimiDefaultScope)
|
||||
|
||||
var proxyURL string
|
||||
if proxyID != nil && proxyRepo != nil {
|
||||
if proxy, err := proxyRepo.GetByID(ctx, *proxyID); err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", kimiDeviceAuthURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create device auth request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.doRequest(req, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device auth request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read device auth response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device auth failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result KimiDeviceAuthResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse device auth response: %w", err)
|
||||
}
|
||||
|
||||
interval := time.Duration(result.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
session := &kimiOAuthSession{
|
||||
DeviceCode: result.DeviceCode,
|
||||
UserCode: result.UserCode,
|
||||
VerificationURI: result.VerificationURI,
|
||||
VerificationURIComplete: result.VerificationURIComplete,
|
||||
ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second),
|
||||
Interval: interval,
|
||||
ProxyURL: proxyURL,
|
||||
}
|
||||
s.sessions.Store(result.DeviceCode, session)
|
||||
|
||||
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Device auth initiated: user_code=%s", result.UserCode)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// PollToken polls the token endpoint for a device_code.
|
||||
func (s *KimiOAuthService) PollToken(ctx context.Context, deviceCode string) (*KimiTokenInfo, error) {
|
||||
sessionVal, ok := s.sessions.Load(deviceCode)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
session := sessionVal.(*kimiOAuthSession)
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
s.sessions.Delete(deviceCode)
|
||||
return nil, fmt.Errorf("device auth session expired")
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||
data.Set("device_code", deviceCode)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.doRequest(req, session.ProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden {
|
||||
// Check for authorization_pending
|
||||
var errResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Error == "authorization_pending" {
|
||||
return nil, fmt.Errorf("authorization_pending")
|
||||
}
|
||||
if errResp.Error == "expired_token" || errResp.Error == "invalid_grant" {
|
||||
s.sessions.Delete(deviceCode)
|
||||
}
|
||||
return nil, fmt.Errorf("token poll failed: %s", errResp.Error)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token poll failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp KimiTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
s.sessions.Delete(deviceCode)
|
||||
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
|
||||
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
|
||||
if expiresAt < minExpiresAt {
|
||||
expiresAt = minExpiresAt
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Token obtained successfully")
|
||||
return &KimiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: tokenResp.Scope,
|
||||
TokenType: tokenResp.TokenType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshToken refreshes the access token using a refresh token.
|
||||
func (s *KimiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*KimiTokenInfo, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("refresh_token is empty")
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", kimiClientID)
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.doRequest(req, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("refresh failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp KimiTokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
|
||||
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
|
||||
if expiresAt < minExpiresAt {
|
||||
expiresAt = minExpiresAt
|
||||
}
|
||||
|
||||
return &KimiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: tokenResp.Scope,
|
||||
TokenType: tokenResp.TokenType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildAccountCredentials builds the credentials map for a Kimi account.
|
||||
func (s *KimiOAuthService) BuildAccountCredentials(tokenInfo *KimiTokenInfo) map[string]any {
|
||||
creds := map[string]any{
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
||||
}
|
||||
if tokenInfo.RefreshToken != "" {
|
||||
creds["refresh_token"] = tokenInfo.RefreshToken
|
||||
}
|
||||
if tokenInfo.TokenType != "" {
|
||||
creds["token_type"] = tokenInfo.TokenType
|
||||
}
|
||||
if tokenInfo.Scope != "" {
|
||||
creds["scope"] = tokenInfo.Scope
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
// Stop cleans up the service.
|
||||
func (s *KimiOAuthService) Stop() {
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
s.sessions.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *KimiOAuthService) doRequest(req *http.Request, proxyURL string) (*http.Response, error) {
|
||||
opts := httpclient.Options{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
client, err := httpclient.GetClient(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
198
backend/internal/service/kimi_token_provider.go
Normal file
198
backend/internal/service/kimi_token_provider.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
kimiTokenRefreshSkew = 3 * time.Minute
|
||||
kimiTokenCacheSkew = 5 * time.Minute
|
||||
)
|
||||
|
||||
// KimiTokenProvider manages access_token for Kimi OAuth accounts.
|
||||
type KimiTokenProvider struct {
|
||||
accountRepo AccountRepository
|
||||
tokenCache GeminiTokenCache
|
||||
kimiOAuthService *KimiOAuthService
|
||||
refreshAPI *OAuthRefreshAPI
|
||||
executor OAuthRefreshExecutor
|
||||
refreshPolicy ProviderRefreshPolicy
|
||||
}
|
||||
|
||||
// NewKimiTokenProvider creates a new KimiTokenProvider.
|
||||
func NewKimiTokenProvider(
|
||||
accountRepo AccountRepository,
|
||||
tokenCache GeminiTokenCache,
|
||||
kimiOAuthService *KimiOAuthService,
|
||||
) *KimiTokenProvider {
|
||||
return &KimiTokenProvider{
|
||||
accountRepo: accountRepo,
|
||||
tokenCache: tokenCache,
|
||||
kimiOAuthService: kimiOAuthService,
|
||||
refreshPolicy: KimiProviderRefreshPolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetRefreshAPI injects unified OAuth refresh API and executor.
|
||||
func (p *KimiTokenProvider) SetRefreshAPI(api *OAuthRefreshAPI, executor OAuthRefreshExecutor) {
|
||||
p.refreshAPI = api
|
||||
p.executor = executor
|
||||
}
|
||||
|
||||
// SetRefreshPolicy injects caller-side refresh policy.
|
||||
func (p *KimiTokenProvider) SetRefreshPolicy(policy ProviderRefreshPolicy) {
|
||||
p.refreshPolicy = policy
|
||||
}
|
||||
|
||||
// GetAccessToken returns a valid access token for the given Kimi account.
|
||||
func (p *KimiTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||
if account == nil {
|
||||
return "", errors.New("account is nil")
|
||||
}
|
||||
if account.Platform != PlatformKimi || account.Type != AccountTypeOAuth {
|
||||
return "", errors.New("not a kimi oauth account")
|
||||
}
|
||||
|
||||
cacheKey := KimiTokenCacheKey(account)
|
||||
|
||||
// 1) Try cache first.
|
||||
if p.tokenCache != nil {
|
||||
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Refresh if needed (pre-expiry skew).
|
||||
expiresAt := account.GetCredentialAsTime("expires_at")
|
||||
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= kimiTokenRefreshSkew
|
||||
|
||||
if needsRefresh && p.refreshAPI != nil && p.executor != nil {
|
||||
result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, kimiTokenRefreshSkew)
|
||||
if err != nil {
|
||||
if p.refreshPolicy.OnRefreshError == ProviderRefreshErrorReturn {
|
||||
return "", err
|
||||
}
|
||||
} else if result.LockHeld {
|
||||
if p.refreshPolicy.OnLockHeld == ProviderLockHeldWaitForCache && p.tokenCache != nil {
|
||||
if token, cacheErr := p.tokenCache.GetAccessToken(ctx, cacheKey); cacheErr == nil && strings.TrimSpace(token) != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
slog.Debug("kimi_token_lock_held_use_old", "account_id", account.ID)
|
||||
} else {
|
||||
account = result.Account
|
||||
expiresAt = account.GetCredentialAsTime("expires_at")
|
||||
}
|
||||
} else if needsRefresh && p.tokenCache != nil {
|
||||
locked, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
|
||||
if lockErr == nil && locked {
|
||||
defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
|
||||
} else if lockErr != nil {
|
||||
slog.Warn("kimi_token_lock_failed", "account_id", account.ID, "error", lockErr)
|
||||
}
|
||||
}
|
||||
|
||||
accessToken := account.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found in credentials")
|
||||
}
|
||||
|
||||
// 3) Populate cache with TTL.
|
||||
if p.tokenCache != nil {
|
||||
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
|
||||
if isStale && latestAccount != nil {
|
||||
slog.Debug("kimi_token_version_stale_use_latest", "account_id", account.ID)
|
||||
accessToken = latestAccount.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found after version check")
|
||||
}
|
||||
} else {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > kimiTokenCacheSkew:
|
||||
ttl = until - kimiTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
// KimiTokenCacheKey returns the cache key for a Kimi account.
|
||||
func KimiTokenCacheKey(account *Account) string {
|
||||
return "kimi:account:" + strconv.FormatInt(account.ID, 10)
|
||||
}
|
||||
|
||||
// KimiProviderRefreshPolicy returns the default refresh policy for Kimi.
|
||||
func KimiProviderRefreshPolicy() ProviderRefreshPolicy {
|
||||
return ProviderRefreshPolicy{
|
||||
OnRefreshError: ProviderRefreshErrorReturn,
|
||||
OnLockHeld: ProviderLockHeldWaitForCache,
|
||||
}
|
||||
}
|
||||
|
||||
// KimiTokenRefresher implements OAuthRefreshExecutor for Kimi.
|
||||
type KimiTokenRefresher struct {
|
||||
kimiOAuthService *KimiOAuthService
|
||||
}
|
||||
|
||||
// NewKimiTokenRefresher creates a new KimiTokenRefresher.
|
||||
func NewKimiTokenRefresher(kimiOAuthService *KimiOAuthService) *KimiTokenRefresher {
|
||||
return &KimiTokenRefresher{kimiOAuthService: kimiOAuthService}
|
||||
}
|
||||
|
||||
// CanRefresh checks if this refresher can handle the given account.
|
||||
func (r *KimiTokenRefresher) CanRefresh(account *Account) bool {
|
||||
return account != nil && account.Platform == PlatformKimi && account.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
// NeedsRefresh checks if the account needs token refresh.
|
||||
func (r *KimiTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
expiresAt := account.GetCredentialAsTime("expires_at")
|
||||
return expiresAt == nil || time.Until(*expiresAt) <= refreshWindow
|
||||
}
|
||||
|
||||
// Refresh performs the token refresh for a Kimi account.
|
||||
func (r *KimiTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) {
|
||||
if account == nil || r.kimiOAuthService == nil {
|
||||
return nil, errors.New("kimi token refresher not initialized")
|
||||
}
|
||||
|
||||
refreshToken := account.GetCredential("refresh_token")
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, errors.New("no refresh token available")
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
tokenInfo, err := r.kimiOAuthService.RefreshToken(ctx, refreshToken, proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.kimiOAuthService.BuildAccountCredentials(tokenInfo), nil
|
||||
}
|
||||
|
||||
// CacheKey returns the cache key for distributed locking.
|
||||
func (r *KimiTokenRefresher) CacheKey(account *Account) string {
|
||||
return KimiTokenCacheKey(account)
|
||||
}
|
||||
@@ -917,7 +917,15 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
|
||||
excludedIDs map[int64]struct{},
|
||||
requiredCapability OpenAIImagesCapability,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
|
||||
selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
|
||||
if err == nil && selection != nil && selection.Account != nil {
|
||||
return selection, decision, nil
|
||||
}
|
||||
// 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号)
|
||||
if requiredCapability == OpenAIImagesCapabilityNative {
|
||||
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic)
|
||||
}
|
||||
return selection, decision, err
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
||||
|
||||
@@ -1294,7 +1294,7 @@ type openAIImageToolMessage struct {
|
||||
}
|
||||
|
||||
func readOpenAIImageConversationStream(resp *req.Response, startTime time.Time) (string, []openAIImagePointerInfo, OpenAIUsage, *int, error) {
|
||||
if resp == nil || resp.Response == nil || resp.Body == nil {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return "", nil, OpenAIUsage{}, nil, fmt.Errorf("empty conversation response")
|
||||
}
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
@@ -1752,8 +1752,8 @@ func newOpenAIImageStatusError(resp *req.Response, fallback string) error {
|
||||
if resp.Response != nil {
|
||||
headers = resp.Header.Clone()
|
||||
requestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
if resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
requestURL = resp.Response.Request.URL.String()
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
requestURL = resp.Request.URL.String()
|
||||
}
|
||||
if resp.Body != nil {
|
||||
body, _ = io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
|
||||
@@ -46,6 +46,9 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
|
||||
keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
|
||||
case PlatformAnthropic:
|
||||
keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
|
||||
case PlatformKimi:
|
||||
keysToDelete = append(keysToDelete, KimiTokenCacheKey(account))
|
||||
keysToDelete = append(keysToDelete, "kimi:"+accountIDKey)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ func NewTokenRefreshService(
|
||||
return s
|
||||
}
|
||||
|
||||
// SetKimiOAuthService 注入 Kimi OAuth 服务并注册刷新器
|
||||
func (s *TokenRefreshService) SetKimiOAuthService(kimiOAuthService *KimiOAuthService) {
|
||||
if kimiOAuthService == nil {
|
||||
return
|
||||
}
|
||||
kimiRefresher := NewKimiTokenRefresher(kimiOAuthService)
|
||||
s.refreshers = append(s.refreshers, kimiRefresher)
|
||||
s.executors = append(s.executors, kimiRefresher)
|
||||
}
|
||||
|
||||
// SetPrivacyDeps 注入 OpenAI privacy opt-out 所需依赖
|
||||
func (s *TokenRefreshService) SetPrivacyDeps(factory PrivacyClientFactory, proxyRepo ProxyRepository) {
|
||||
s.privacyClientFactory = factory
|
||||
|
||||
@@ -134,6 +134,7 @@ type UserIdentitySummary struct {
|
||||
BindStartPath string `json:"bind_start_path,omitempty"`
|
||||
CanBind bool `json:"can_bind"`
|
||||
CanUnbind bool `json:"can_unbind"`
|
||||
NoteKey string `json:"note_key,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
@@ -156,6 +157,12 @@ type StartUserIdentityBindingResult struct {
|
||||
UseBrowserRedirect bool `json:"use_browser_redirect"`
|
||||
}
|
||||
|
||||
const (
|
||||
userIdentityNoteEmailManagedFromProfile = "profile.authBindings.notes.emailManagedFromProfile"
|
||||
userIdentityNoteCanUnbind = "profile.authBindings.notes.canUnbind"
|
||||
userIdentityNoteBindAnotherBeforeUnbind = "profile.authBindings.notes.bindAnotherBeforeUnbind"
|
||||
)
|
||||
|
||||
// UpdateProfileRequest 更新用户资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
@@ -601,6 +608,7 @@ func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthId
|
||||
Provider: "email",
|
||||
CanBind: false,
|
||||
CanUnbind: false,
|
||||
NoteKey: userIdentityNoteEmailManagedFromProfile,
|
||||
Note: "Primary account email is managed from the profile form.",
|
||||
}
|
||||
if user == nil {
|
||||
@@ -668,8 +676,10 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
|
||||
summary.VerifiedAt = primary.VerifiedAt
|
||||
summary.CanUnbind = s.canUnbindProvider(provider, user, records)
|
||||
if summary.CanUnbind {
|
||||
summary.NoteKey = userIdentityNoteCanUnbind
|
||||
summary.Note = "You can unbind this sign-in method."
|
||||
} else {
|
||||
summary.NoteKey = userIdentityNoteBindAnotherBeforeUnbind
|
||||
summary.Note = "Bind another sign-in method before unbinding."
|
||||
}
|
||||
return summary
|
||||
|
||||
@@ -46,6 +46,7 @@ func ProvideTokenRefreshService(
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
geminiOAuthService *GeminiOAuthService,
|
||||
antigravityOAuthService *AntigravityOAuthService,
|
||||
kimiOAuthService *KimiOAuthService,
|
||||
cacheInvalidator TokenCacheInvalidator,
|
||||
schedulerCache SchedulerCache,
|
||||
cfg *config.Config,
|
||||
@@ -55,6 +56,8 @@ func ProvideTokenRefreshService(
|
||||
refreshAPI *OAuthRefreshAPI,
|
||||
) *TokenRefreshService {
|
||||
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, schedulerCache, cfg, tempUnschedCache)
|
||||
// 注入 Kimi OAuth 服务
|
||||
svc.SetKimiOAuthService(kimiOAuthService)
|
||||
// 注入 OpenAI privacy opt-out 依赖
|
||||
svc.SetPrivacyDeps(privacyClientFactory, proxyRepo)
|
||||
// 注入统一 OAuth 刷新 API(消除 TokenRefreshService 与 TokenProvider 之间的竞争条件)
|
||||
@@ -123,6 +126,20 @@ func ProvideAntigravityTokenProvider(
|
||||
return p
|
||||
}
|
||||
|
||||
// ProvideKimiTokenProvider creates KimiTokenProvider with OAuthRefreshAPI injection
|
||||
func ProvideKimiTokenProvider(
|
||||
accountRepo AccountRepository,
|
||||
tokenCache GeminiTokenCache,
|
||||
kimiOAuthService *KimiOAuthService,
|
||||
refreshAPI *OAuthRefreshAPI,
|
||||
) *KimiTokenProvider {
|
||||
p := NewKimiTokenProvider(accountRepo, tokenCache, kimiOAuthService)
|
||||
executor := NewKimiTokenRefresher(kimiOAuthService)
|
||||
p.SetRefreshAPI(refreshAPI, executor)
|
||||
p.SetRefreshPolicy(KimiProviderRefreshPolicy())
|
||||
return p
|
||||
}
|
||||
|
||||
// ProvideDashboardAggregationService 创建并启动仪表盘聚合服务
|
||||
func ProvideDashboardAggregationService(repo DashboardAggregationRepository, timingWheel *TimingWheelService, cfg *config.Config) *DashboardAggregationService {
|
||||
svc := NewDashboardAggregationService(repo, timingWheel, cfg)
|
||||
@@ -409,8 +426,10 @@ var ProviderSet = wire.NewSet(
|
||||
NewCompositeTokenCacheInvalidator,
|
||||
wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)),
|
||||
NewAntigravityOAuthService,
|
||||
NewKimiOAuthService,
|
||||
NewOAuthRefreshAPI,
|
||||
ProvideGeminiTokenProvider,
|
||||
ProvideKimiTokenProvider,
|
||||
NewGeminiMessagesCompatService,
|
||||
ProvideAntigravityTokenProvider,
|
||||
ProvideOpenAITokenProvider,
|
||||
|
||||
28
deploy/docker-compose.test.yml
Normal file
28
deploy/docker-compose.test.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: sub2api-test-postgres
|
||||
environment:
|
||||
POSTGRES_USER: sub2api
|
||||
POSTGRES_PASSWORD: sub2api
|
||||
POSTGRES_DB: sub2api
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- ./postgres_test_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U sub2api -d sub2api"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: sub2api-test-redis
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
243
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
243
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# ADMIN_PAYMENT_INTEGRATION_API
|
||||
|
||||
> 单文件中英双语文档 / Single-file bilingual documentation (Chinese + English)
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 目标
|
||||
本文档用于对接外部支付系统(如 `sub2apipay`)与 Sub2API 的 Admin API,覆盖:
|
||||
- 支付成功后充值
|
||||
- 用户查询
|
||||
- 人工余额修正
|
||||
- 前端购买页参数透传
|
||||
|
||||
### 基础地址
|
||||
- 生产:`https://<your-domain>`
|
||||
- Beta:`http://<your-server-ip>:8084`
|
||||
|
||||
### 认证
|
||||
推荐使用:
|
||||
- `x-api-key: admin-<64hex>`
|
||||
- `Content-Type: application/json`
|
||||
- 幂等接口额外传:`Idempotency-Key`
|
||||
|
||||
说明:管理员 JWT 也可访问 admin 路由,但服务间调用建议使用 Admin API Key。
|
||||
|
||||
### 1) 一步完成创建并兑换
|
||||
`POST /api/v1/admin/redeem-codes/create-and-redeem`
|
||||
|
||||
用途:原子完成“创建兑换码 + 兑换到指定用户”。
|
||||
|
||||
请求头:
|
||||
- `x-api-key`
|
||||
- `Idempotency-Key`
|
||||
|
||||
请求体示例:
|
||||
```json
|
||||
{
|
||||
"code": "s2p_cm1234567890",
|
||||
"type": "balance",
|
||||
"value": 100.0,
|
||||
"user_id": 123,
|
||||
"notes": "sub2apipay order: cm1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
幂等语义:
|
||||
- 同 `code` 且 `used_by` 一致:`200`
|
||||
- 同 `code` 但 `used_by` 不一致:`409`
|
||||
- 缺少 `Idempotency-Key`:`400`(`IDEMPOTENCY_KEY_REQUIRED`)
|
||||
|
||||
curl 示例:
|
||||
```bash
|
||||
curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \
|
||||
-H "x-api-key: ${KEY}" \
|
||||
-H "Idempotency-Key: pay-cm1234567890-success" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"code":"s2p_cm1234567890",
|
||||
"type":"balance",
|
||||
"value":100.00,
|
||||
"user_id":123,
|
||||
"notes":"sub2apipay order: cm1234567890"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2) 查询用户(可选前置校验)
|
||||
`GET /api/v1/admin/users/:id`
|
||||
|
||||
```bash
|
||||
curl -s "${BASE}/api/v1/admin/users/123" \
|
||||
-H "x-api-key: ${KEY}"
|
||||
```
|
||||
|
||||
### 3) 余额调整(已有接口)
|
||||
`POST /api/v1/admin/users/:id/balance`
|
||||
|
||||
用途:人工补偿 / 扣减,支持 `set` / `add` / `subtract`。
|
||||
|
||||
请求体示例(扣减):
|
||||
```json
|
||||
{
|
||||
"balance": 100.0,
|
||||
"operation": "subtract",
|
||||
"notes": "manual correction"
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "${BASE}/api/v1/admin/users/123/balance" \
|
||||
-H "x-api-key: ${KEY}" \
|
||||
-H "Idempotency-Key: balance-subtract-cm1234567890" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"balance":100.00,
|
||||
"operation":"subtract",
|
||||
"notes":"manual correction"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4) 购买页 / 自定义页面 URL Query 透传(iframe / 新窗口一致)
|
||||
当 Sub2API 打开 `purchase_subscription_url` 或用户侧自定义页面 iframe URL 时,会统一追加:
|
||||
- `user_id`
|
||||
- `token`
|
||||
- `theme`(`light` / `dark`)
|
||||
- `lang`(例如 `zh` / `en`,用于向嵌入页传递当前界面语言)
|
||||
- `ui_mode`(固定 `embedded`)
|
||||
|
||||
示例:
|
||||
```text
|
||||
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&lang=zh&ui_mode=embedded
|
||||
```
|
||||
|
||||
### 5) 失败处理建议
|
||||
- 支付成功与充值成功分状态落库
|
||||
- 回调验签成功后立即标记“支付成功”
|
||||
- 支付成功但充值失败的订单允许后续重试
|
||||
- 重试保持相同 `code`,并使用新的 `Idempotency-Key`
|
||||
|
||||
### 6) `doc_url` 配置建议
|
||||
- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||
- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Purpose
|
||||
This document describes the minimal Sub2API Admin API surface for external payment integrations (for example, `sub2apipay`), including:
|
||||
- Recharge after payment success
|
||||
- User lookup
|
||||
- Manual balance correction
|
||||
- Purchase page query parameter forwarding
|
||||
|
||||
### Base URL
|
||||
- Production: `https://<your-domain>`
|
||||
- Beta: `http://<your-server-ip>:8084`
|
||||
|
||||
### Authentication
|
||||
Recommended headers:
|
||||
- `x-api-key: admin-<64hex>`
|
||||
- `Content-Type: application/json`
|
||||
- `Idempotency-Key` for idempotent endpoints
|
||||
|
||||
Note: Admin JWT can also access admin routes, but Admin API Key is recommended for server-to-server integration.
|
||||
|
||||
### 1) Create and Redeem in one step
|
||||
`POST /api/v1/admin/redeem-codes/create-and-redeem`
|
||||
|
||||
Use case: atomically create a redeem code and redeem it to a target user.
|
||||
|
||||
Headers:
|
||||
- `x-api-key`
|
||||
- `Idempotency-Key`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"code": "s2p_cm1234567890",
|
||||
"type": "balance",
|
||||
"value": 100.0,
|
||||
"user_id": 123,
|
||||
"notes": "sub2apipay order: cm1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
Idempotency behavior:
|
||||
- Same `code` and same `used_by`: `200`
|
||||
- Same `code` but different `used_by`: `409`
|
||||
- Missing `Idempotency-Key`: `400` (`IDEMPOTENCY_KEY_REQUIRED`)
|
||||
|
||||
curl example:
|
||||
```bash
|
||||
curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \
|
||||
-H "x-api-key: ${KEY}" \
|
||||
-H "Idempotency-Key: pay-cm1234567890-success" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"code":"s2p_cm1234567890",
|
||||
"type":"balance",
|
||||
"value":100.00,
|
||||
"user_id":123,
|
||||
"notes":"sub2apipay order: cm1234567890"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2) Query User (optional pre-check)
|
||||
`GET /api/v1/admin/users/:id`
|
||||
|
||||
```bash
|
||||
curl -s "${BASE}/api/v1/admin/users/123" \
|
||||
-H "x-api-key: ${KEY}"
|
||||
```
|
||||
|
||||
### 3) Balance Adjustment (existing API)
|
||||
`POST /api/v1/admin/users/:id/balance`
|
||||
|
||||
Use case: manual correction with `set` / `add` / `subtract`.
|
||||
|
||||
Request body example (`subtract`):
|
||||
```json
|
||||
{
|
||||
"balance": 100.0,
|
||||
"operation": "subtract",
|
||||
"notes": "manual correction"
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "${BASE}/api/v1/admin/users/123/balance" \
|
||||
-H "x-api-key: ${KEY}" \
|
||||
-H "Idempotency-Key: balance-subtract-cm1234567890" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"balance":100.00,
|
||||
"operation":"subtract",
|
||||
"notes":"manual correction"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4) Purchase / Custom Page URL query forwarding (iframe and new tab)
|
||||
When Sub2API opens `purchase_subscription_url` or a user-facing custom page iframe URL, it appends:
|
||||
- `user_id`
|
||||
- `token`
|
||||
- `theme` (`light` / `dark`)
|
||||
- `lang` (for example `zh` / `en`, used to pass the current UI language to the embedded page)
|
||||
- `ui_mode` (fixed: `embedded`)
|
||||
|
||||
Example:
|
||||
```text
|
||||
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&lang=zh&ui_mode=embedded
|
||||
```
|
||||
|
||||
### 5) Failure handling recommendations
|
||||
- Persist payment success and recharge success as separate states
|
||||
- Mark payment as successful immediately after verified callback
|
||||
- Allow retry for orders with payment success but recharge failure
|
||||
- Keep the same `code` for retry, and use a new `Idempotency-Key`
|
||||
|
||||
### 6) Recommended `doc_url`
|
||||
- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||
- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||
287
docs/PAYMENT.md
Normal file
287
docs/PAYMENT.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Payment System Configuration Guide
|
||||
|
||||
Sub2API has a built-in payment system that enables user self-service top-up without deploying a separate payment service.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Supported Payment Methods](#supported-payment-methods)
|
||||
- [Quick Start](#quick-start)
|
||||
- [System Settings](#system-settings)
|
||||
- [Provider Configuration](#provider-configuration)
|
||||
- [Provider Instance Management](#provider-instance-management)
|
||||
- [Webhook Configuration](#webhook-configuration)
|
||||
- [Payment Flow](#payment-flow)
|
||||
- [Migrating from Sub2ApiPay](#migrating-from-sub2apipay)
|
||||
|
||||
---
|
||||
|
||||
## Supported Payment Methods
|
||||
|
||||
| Provider | Payment Methods | Description |
|
||||
|----------|----------------|-------------|
|
||||
| **EasyPay** | Alipay, WeChat Pay | Third-party aggregation via EasyPay protocol |
|
||||
| **Alipay (Direct)** | Desktop QR code, mobile Alipay redirect | Direct integration with Alipay Open Platform, returning desktop QR codes and mobile WAP/app launch links |
|
||||
| **WeChat Pay (Direct)** | Native QR, H5, MP/JSAPI Pay | Direct integration with WeChat Pay APIv3 with environment-aware routing |
|
||||
| **Stripe** | Card, Alipay, WeChat Pay, Link, etc. | International payments, multi-currency support |
|
||||
|
||||
> Alipay/WeChat Pay direct and EasyPay can both exist as backend provider instances, but the frontend always exposes only two visible buttons: `Alipay` and `WeChat Pay`. Admins choose exactly one source for each visible method: direct or EasyPay. Direct channels connect to payment APIs directly with lower fees; EasyPay aggregates through third-party platforms with easier setup.
|
||||
|
||||
> **EasyPay Provider Recommendations**: Both options below are third-party aggregators compatible with the EasyPay protocol. Pick based on the funding channel and settlement currency you need:
|
||||
>
|
||||
> - **Domestic channel / CNY settlement** — [ZPay](https://z-pay.cn/?uid=23808) (`https://z-pay.cn/?uid=23808`): direct integration with official Alipay / WeChat Pay APIs, fee **1.6%**; funds go straight to the merchant account with **T+1 automatic settlement**. Supports **individual users** (no business license required) with up to 10,000 CNY daily transactions; business-licensed accounts have no limit. Link contains the referral code of [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) original author [@touwaeriol](https://github.com/touwaeriol) — feel free to remove it.
|
||||
> - **International channel / USDT or USD settlement** — [Kyren Topup](https://kyren.top/?code=SUB2API) (`https://kyren.top/?code=SUB2API`): a ready-to-launch global payment stack for AI startups with WeChat Pay and Alipay support, local-currency checkout, and USD settlement. Fees: WeChat 2%, Alipay 2.5%; withdrawal 0.1% (min $40, max $150), settled in **USDT or USD**. No qualification review required — sign up and use immediately, making it the lowest barrier to entry. Withdrawal threshold is relatively high, recommended for users **who do not use domestic Chinese payment channels, cannot tolerate Stripe's 6%+ fees, have high transaction volume, and have USD or USDT channels to receive withdrawn funds**. Kyren Topup charges a $200 account opening fee; signing up via this link (which contains Sub2Api author [@Wei-Shaw](https://github.com/Wei-Shaw)'s referral code) **waives the opening fee**. Feel free to remove it if you prefer.
|
||||
>
|
||||
> Please evaluate the security, reliability, and compliance of any third-party payment provider on your own — this project does not endorse or guarantee any of them.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Go to Admin Dashboard → **Settings** → **Payment Settings** tab
|
||||
2. Enable **Payment**
|
||||
3. Configure basic parameters (amount range, timeout, etc.)
|
||||
4. Add at least one provider instance in **Provider Management**
|
||||
5. Users can now top up from the frontend
|
||||
|
||||
---
|
||||
|
||||
## System Settings
|
||||
|
||||
Configure the following in Admin Dashboard **Settings → Payment Settings**:
|
||||
|
||||
### Basic Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Enable Payment** | Enable or disable the payment system | Off |
|
||||
| **Product Name Prefix** | Prefix shown on payment page | - |
|
||||
| **Product Name Suffix** | Suffix (e.g., "Credits") | - |
|
||||
| **Minimum Amount** | Minimum single top-up amount | 1 |
|
||||
| **Maximum Amount** | Maximum single top-up amount (empty = unlimited) | - |
|
||||
| **Daily Limit** | Per-user daily cumulative limit (empty = unlimited) | - |
|
||||
| **Order Timeout** | Order timeout in minutes (minimum 1) | 30 |
|
||||
| **Max Pending Orders** | Maximum concurrent pending orders per user | 3 |
|
||||
| **Load Balance Strategy** | Strategy for selecting provider instances | Round Robin |
|
||||
|
||||
### Frontend Visible Method Routing
|
||||
|
||||
The current payment UX keeps the frontend method list unified and does not expose provider brands directly:
|
||||
|
||||
- **Alipay**: when enabled, this button must be routed to either `Alipay (Direct)` or `EasyPay Alipay`
|
||||
- **WeChat Pay**: when enabled, this button must be routed to either `WeChat Pay (Direct)` or `EasyPay WeChat`
|
||||
- Each visible method can route to only one source at a time
|
||||
- If a visible method is enabled without a selected source, the frontend will not expose that method
|
||||
|
||||
### Load Balance Strategies
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| **Round Robin** | Distribute orders to instances in rotation |
|
||||
| **Least Amount** | Prefer instances with the lowest daily cumulative amount |
|
||||
|
||||
### Cancel Rate Limiting
|
||||
|
||||
Prevents users from repeatedly creating and canceling orders:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Enable Limit** | Toggle |
|
||||
| **Window Mode** | Sliding / Fixed window |
|
||||
| **Time Window** | Window duration |
|
||||
| **Window Unit** | Minutes / Hours |
|
||||
| **Max Cancels** | Maximum cancellations allowed within the window |
|
||||
|
||||
### Help Information
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Help Image** | Customer service QR code or help image (supports upload) |
|
||||
| **Help Text** | Instructions displayed on the payment page |
|
||||
|
||||
---
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
Each provider type requires different credentials. Select the type when adding a new provider instance in **Provider Management → Add Provider**.
|
||||
|
||||
> **Callback URLs are auto-generated**: When adding a provider, the Notify URL and Return URL are automatically constructed from your site domain. You only need to confirm the domain is correct.
|
||||
|
||||
### EasyPay
|
||||
|
||||
Compatible with any payment service that implements the EasyPay protocol.
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| **Merchant ID (PID)** | EasyPay merchant ID | Yes |
|
||||
| **Merchant Key (PKey)** | EasyPay merchant secret key | Yes |
|
||||
| **API Base URL** | EasyPay API base address | Yes |
|
||||
| **Alipay Channel ID** | Specify Alipay channel (optional) | No |
|
||||
| **WeChat Channel ID** | Specify WeChat channel (optional) | No |
|
||||
|
||||
### Alipay (Direct)
|
||||
|
||||
Direct integration with Alipay Open Platform. Desktop flows return a QR code for in-page display, while mobile flows return an Alipay WAP/app redirect URL.
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| **AppID** | Alipay application AppID | Yes |
|
||||
| **Private Key** | RSA2 application private key | Yes |
|
||||
| **Alipay Public Key** | Alipay public key | Yes |
|
||||
|
||||
### WeChat Pay (Direct)
|
||||
|
||||
Direct integration with WeChat Pay APIv3. Supports Native QR code payment, H5 payment, and MP/JSAPI payment inside the WeChat environment.
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| **AppID** | WeChat Pay AppID | Yes |
|
||||
| **Merchant ID (MchID)** | WeChat Pay merchant ID | Yes |
|
||||
| **Merchant API Private Key** | Merchant API private key (PEM format) | Yes |
|
||||
| **APIv3 Key** | 32-byte APIv3 key | Yes |
|
||||
| **WeChat Pay Public Key** | WeChat Pay public key (PEM format) | Yes |
|
||||
| **WeChat Pay Public Key ID** | WeChat Pay public key ID | Yes |
|
||||
| **Certificate Serial Number** | Merchant certificate serial number | Yes |
|
||||
|
||||
### Stripe
|
||||
|
||||
International payment platform supporting multiple payment methods and currencies.
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| **Secret Key** | Stripe secret key (`sk_live_...` or `sk_test_...`) | Yes |
|
||||
| **Publishable Key** | Stripe publishable key (`pk_live_...` or `pk_test_...`) | Yes |
|
||||
| **Webhook Secret** | Stripe Webhook signing secret (`whsec_...`) | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Provider Instance Management
|
||||
|
||||
You can create **multiple instances** of the same provider type for load balancing and risk control:
|
||||
|
||||
- **Multi-instance load balancing** — Distribute orders via round-robin or least-amount strategy
|
||||
- **Independent limits** — Each instance can have its own min/max amount and daily limit
|
||||
- **Independent toggle** — Enable/disable individual instances without affecting others
|
||||
- **Refund control** — Enable or disable refunds per instance
|
||||
- **Payment methods** — Each instance can support a subset of payment methods
|
||||
- **Ordering** — Drag to reorder instances
|
||||
|
||||
### Instance Limit Configuration
|
||||
|
||||
Each instance supports these limits:
|
||||
|
||||
| Limit | Description |
|
||||
|-------|-------------|
|
||||
| **Minimum Amount** | Minimum order amount accepted by this instance |
|
||||
| **Maximum Amount** | Maximum order amount accepted by this instance |
|
||||
| **Daily Limit** | Daily cumulative transaction limit for this instance |
|
||||
|
||||
> During load balancing, instances that exceed their limits are automatically skipped.
|
||||
|
||||
---
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
Payment callbacks are essential for the payment system to work correctly.
|
||||
|
||||
### Callback URL Format
|
||||
|
||||
When adding a provider, the system auto-generates callback URLs from your site domain:
|
||||
|
||||
| Provider | Callback Path |
|
||||
|----------|-------------|
|
||||
| **EasyPay** | `https://your-domain.com/api/v1/payment/webhook/easypay` |
|
||||
| **Alipay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/alipay` |
|
||||
| **WeChat Pay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/wxpay` |
|
||||
| **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` |
|
||||
|
||||
> Replace `your-domain.com` with your actual domain. For EasyPay / Alipay / WeChat Pay, the callback URL is auto-filled when adding the provider — no manual configuration needed.
|
||||
|
||||
### Stripe Webhook Setup
|
||||
|
||||
1. Log in to [Stripe Dashboard](https://dashboard.stripe.com/)
|
||||
2. Go to **Developers → Webhooks**
|
||||
3. Add an endpoint with the callback URL
|
||||
4. Subscribe to events: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
||||
5. Copy the generated Webhook Secret (`whsec_...`) to your provider configuration
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Callback URLs must use **HTTPS** (required by Stripe, strongly recommended for others)
|
||||
- Ensure your firewall allows callback requests from payment platforms
|
||||
- The system automatically verifies callback signatures to prevent forgery
|
||||
- Balance top-up is processed automatically upon successful payment — no manual intervention needed
|
||||
|
||||
---
|
||||
|
||||
## Payment Flow
|
||||
|
||||
```
|
||||
User selects amount and payment method
|
||||
│
|
||||
▼
|
||||
Create Order (PENDING)
|
||||
├─ Validate amount range, pending order count, daily limit
|
||||
├─ Load balance to select provider instance
|
||||
└─ Call provider to get payment info
|
||||
│
|
||||
▼
|
||||
User completes payment
|
||||
├─ EasyPay → QR code / H5 redirect
|
||||
├─ Alipay → Desktop QR / mobile Alipay redirect
|
||||
├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI
|
||||
└─ Stripe → Payment Element (card/Alipay/WeChat/etc.)
|
||||
│
|
||||
▼
|
||||
Webhook callback verified → Order PAID
|
||||
│
|
||||
▼
|
||||
Auto top-up to user balance → Order COMPLETED
|
||||
```
|
||||
|
||||
### Order Status Reference
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `PENDING` | Waiting for user to complete payment |
|
||||
| `PAID` | Payment confirmed, awaiting balance credit |
|
||||
| `COMPLETED` | Balance credited successfully |
|
||||
| `EXPIRED` | Timed out without payment |
|
||||
| `CANCELLED` | Cancelled by user |
|
||||
| `FAILED` | Balance credit failed, admin can retry |
|
||||
| `REFUND_REQUESTED` | Refund requested |
|
||||
| `REFUNDING` | Refund in progress |
|
||||
| `REFUNDED` | Refund completed |
|
||||
|
||||
### Timeout and Fallback
|
||||
|
||||
- Before marking an order as expired, the background job queries the upstream payment status first
|
||||
- If the user has actually paid but the callback was delayed, the system will reconcile automatically
|
||||
- The background job runs every 60 seconds to check for timed-out orders
|
||||
|
||||
---
|
||||
|
||||
## Migrating from Sub2ApiPay
|
||||
|
||||
If you previously used [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) as an external payment system, you can migrate to the built-in payment system:
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Sub2ApiPay | Built-in Payment |
|
||||
|--------|-----------|-----------------|
|
||||
| Deployment | Separate service (Next.js + PostgreSQL) | Built into Sub2API, no extra deployment |
|
||||
| Payment Methods | EasyPay, Alipay, WeChat, Stripe | Same |
|
||||
| Configuration | Environment variables + separate admin UI | Unified in Sub2API admin dashboard |
|
||||
| Top-up Integration | Via Admin API callback | Internal processing, more reliable |
|
||||
| Subscription Plans | Supported | Not yet (planned) |
|
||||
| Order Management | Separate admin interface | Integrated in Sub2API admin dashboard |
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Enable payment in Sub2API admin dashboard and configure providers (use the same payment credentials)
|
||||
2. Update webhook callback URLs to Sub2API's callback endpoints
|
||||
3. Verify that new orders are processed correctly via built-in payment
|
||||
4. Decommission the Sub2ApiPay service
|
||||
|
||||
> **Note**: Historical order data from Sub2ApiPay will not be automatically migrated. Keep Sub2ApiPay running for a while to access historical records.
|
||||
287
docs/PAYMENT_CN.md
Normal file
287
docs/PAYMENT_CN.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 支付系统配置指南
|
||||
|
||||
Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支付服务。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [支持的支付方式](#支持的支付方式)
|
||||
- [快速开始](#快速开始)
|
||||
- [系统设置](#系统设置)
|
||||
- [服务商配置](#服务商配置)
|
||||
- [服务商实例管理](#服务商实例管理)
|
||||
- [Webhook 配置](#webhook-配置)
|
||||
- [支付流程](#支付流程)
|
||||
- [从 Sub2ApiPay 迁移](#从-sub2apipay-迁移)
|
||||
|
||||
---
|
||||
|
||||
## 支持的支付方式
|
||||
|
||||
| 服务商 | 支付方式 | 说明 |
|
||||
|--------|---------|------|
|
||||
| **EasyPay(易支付)** | 支付宝、微信支付 | 兼容易支付协议的第三方聚合支付 |
|
||||
| **支付宝官方** | 桌面二维码扫码、移动端支付宝跳转 | 直接对接支付宝开放平台,桌面端返回二维码,移动端返回 WAP/唤起链接 |
|
||||
| **微信官方** | Native 扫码、H5、公众号/JSAPI 支付 | 直接对接微信支付 APIv3,按终端环境自动分流 |
|
||||
| **Stripe** | 银行卡、支付宝、微信支付、Link 等 | 国际支付,支持多币种 |
|
||||
|
||||
> 支付宝官方 / 微信官方与易支付可以同时作为后台服务商实例存在,但前台始终只展示 `支付宝`、`微信支付` 两个可见按钮。管理员需要分别为这两个按钮选择唯一支付来源:官方或易支付。官方渠道直接对接 API,资金直达商户账户,手续费更低;易支付通过第三方平台聚合,接入门槛更低。
|
||||
|
||||
> **易支付服务商推荐**:以下两家均为兼容易支付协议的第三方聚合支付,按资金通道与结算方式选择:
|
||||
>
|
||||
> - **国内渠道 / 人民币结算** — [ZPay](https://z-pay.cn/?uid=23808)(`https://z-pay.cn/?uid=23808`):支付宝 / 微信官方 API 直连,手续费 **1.6%**;资金直达商家账户,**T+1 自动到账**。支持**个人用户**(无营业执照)每日 1 万元以内交易;拥有营业执照则无限额。链接含 [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) 原作者 [@touwaeriol](https://github.com/touwaeriol) 的邀请码,介意可去掉。
|
||||
> - **国际渠道 / USDT 或美元结算** — [启润支付](https://kyren.top/?code=SUB2API)(`https://kyren.top/?code=SUB2API`):为 AI 项目提供低门槛国际收款通道,支持国际版微信支付与支付宝,本地货币支付、美元结算。手续费:微信 2%、支付宝 2.5%;提现 0.1%(最低 40 美元、最高 150 美元),以 **USDT 或美元**到账。无资质审核、注册即用,使用门槛最低;提现门槛略高,适合**不使用国内支付渠道、无法接受 Stripe 高达 6%+ 手续费、流水较大,且拥有美元或 USDT 渠道可接收提现资金**的用户。启润支付开户费 200 美元,通过本链接注册(含 Sub2Api 作者 [@Wei-Shaw](https://github.com/Wei-Shaw) 邀请码)可**免开户费**,介意可去掉。
|
||||
>
|
||||
> 支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 进入管理后台 → **设置** → **支付设置** 标签页
|
||||
2. 开启 **启用支付**
|
||||
3. 配置基本参数(金额范围、超时时间等)
|
||||
4. 在 **服务商管理** 中添加至少一个服务商实例
|
||||
5. 用户即可在前端页面进行充值
|
||||
|
||||
---
|
||||
|
||||
## 系统设置
|
||||
|
||||
在管理后台 **设置 → 支付设置** 中配置以下参数:
|
||||
|
||||
### 基本设置
|
||||
|
||||
| 设置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| **启用支付** | 启用或禁用支付系统 | 关闭 |
|
||||
| **商品名前缀** | 支付页面显示的商品名前缀 | - |
|
||||
| **商品名后缀** | 商品名后缀(如"元") | - |
|
||||
| **最低金额** | 单笔最低充值金额 | 1 |
|
||||
| **最高金额** | 单笔最高充值金额(留空表示不限制) | - |
|
||||
| **每日限额** | 每用户每日累计充值上限(留空表示不限制) | - |
|
||||
| **订单超时时间** | 订单超时分钟数,至少 1 分钟 | 30 |
|
||||
| **最大待支付订单数** | 同一用户最大并行待支付订单数 | 3 |
|
||||
| **负载均衡策略** | 多服务商实例时的选择策略 | 轮询 |
|
||||
|
||||
### 前台可见支付方式路由
|
||||
|
||||
当前版本对用户统一展示支付方式,不区分官方渠道还是易支付:
|
||||
|
||||
- **支付宝**:后台启用后,需要额外指定该按钮路由到 `支付宝官方` 或 `易支付支付宝`
|
||||
- **微信支付**:后台启用后,需要额外指定该按钮路由到 `微信官方` 或 `易支付微信`
|
||||
- 同一个可见支付方式在同一时刻只能路由到一个来源
|
||||
- 支付来源未选择时,即使对应按钮被开启,前台也不会暴露该支付方式
|
||||
|
||||
### 负载均衡策略
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| **轮询(round-robin)** | 按顺序轮流分配到各服务商实例 |
|
||||
| **最少金额(least-amount)** | 优先分配到当日累计金额最少的实例 |
|
||||
|
||||
### 取消频率限制
|
||||
|
||||
防止用户频繁创建并取消订单:
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|--------|------|
|
||||
| **启用限制** | 开关 |
|
||||
| **窗口模式** | 滚动窗口 / 固定窗口 |
|
||||
| **时间窗口** | 窗口长度 |
|
||||
| **窗口单位** | 分钟 / 小时 |
|
||||
| **最大次数** | 窗口内允许的最大取消次数 |
|
||||
|
||||
### 帮助信息
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|--------|------|
|
||||
| **帮助图片** | 充值页面显示的客服二维码等图片(支持上传) |
|
||||
| **帮助文本** | 充值页面显示的说明文字 |
|
||||
|
||||
---
|
||||
|
||||
## 服务商配置
|
||||
|
||||
每种服务商需要不同的凭证和参数。在 **服务商管理 → 添加服务商** 中选择类型后填写。
|
||||
|
||||
> **回调地址自动生成**:添加服务商时,异步回调地址(Notify URL)和同步跳转地址(Return URL)由系统根据你的站点域名自动拼接,无需手动填写。管理员只需确认域名正确即可。
|
||||
|
||||
### EasyPay(易支付)
|
||||
|
||||
兼容任何 EasyPay 协议的支付服务商。
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| **商户 ID(PID)** | EasyPay 商户 ID | 是 |
|
||||
| **商户密钥(PKey)** | EasyPay 商户密钥 | 是 |
|
||||
| **API 地址** | EasyPay API 基础地址 | 是 |
|
||||
| **支付宝通道 ID** | 指定支付宝通道(可选) | 否 |
|
||||
| **微信通道 ID** | 指定微信通道(可选) | 否 |
|
||||
|
||||
### 支付宝官方
|
||||
|
||||
直接对接支付宝开放平台。桌面端返回二维码供页面内展示和扫码,移动端返回支付宝手机网站支付跳转链接。
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| **AppID** | 支付宝应用 AppID | 是 |
|
||||
| **应用私钥** | RSA2 应用私钥 | 是 |
|
||||
| **支付宝公钥** | 支付宝公钥 | 是 |
|
||||
|
||||
### 微信官方
|
||||
|
||||
直接对接微信支付 APIv3,支持 Native 扫码支付、H5 支付,以及在微信环境内的公众号/JSAPI 支付。
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| **AppID** | 微信支付 AppID | 是 |
|
||||
| **商户号(MchID)** | 微信支付商户号 | 是 |
|
||||
| **商户 API 私钥** | 商户 API 私钥(PEM 格式) | 是 |
|
||||
| **APIv3 密钥** | 32 位 APIv3 密钥 | 是 |
|
||||
| **微信支付公钥** | 微信支付公钥(PEM 格式) | 是 |
|
||||
| **微信支付公钥 ID** | 微信支付公钥 ID | 是 |
|
||||
| **商户证书序列号** | 商户证书序列号 | 是 |
|
||||
|
||||
### Stripe
|
||||
|
||||
国际支付平台,支持多种支付方式和币种。
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| **Secret Key** | Stripe 密钥(`sk_live_...` 或 `sk_test_...`) | 是 |
|
||||
| **Publishable Key** | Stripe 可公开密钥(`pk_live_...` 或 `pk_test_...`) | 是 |
|
||||
| **Webhook Secret** | Stripe Webhook 签名密钥(`whsec_...`) | 是 |
|
||||
|
||||
---
|
||||
|
||||
## 服务商实例管理
|
||||
|
||||
同一种服务商可以创建**多个实例**,实现负载均衡和风控:
|
||||
|
||||
- **多实例负载均衡** — 按轮询或最少金额策略分流订单
|
||||
- **独立限额** — 每个实例可独立配置单笔最小/最大金额和每日限额
|
||||
- **独立启停** — 可单独启用/禁用某个实例,不影响其他实例
|
||||
- **退款控制** — 每个实例可单独开启或关闭退款功能
|
||||
- **支付方式** — 每个实例可选择支持的支付方式子集
|
||||
- **排序** — 拖拽调整实例顺序
|
||||
|
||||
### 实例限额配置
|
||||
|
||||
每个实例支持以下限额:
|
||||
|
||||
| 限额项 | 说明 |
|
||||
|--------|------|
|
||||
| **单笔最小金额** | 该实例接受的最小订单金额 |
|
||||
| **单笔最大金额** | 该实例接受的最大订单金额 |
|
||||
| **每日限额** | 该实例每日累计交易上限 |
|
||||
|
||||
> 负载均衡时,系统会自动跳过超出限额的实例。
|
||||
|
||||
---
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
支付回调是支付系统的核心环节,必须正确配置:
|
||||
|
||||
### 回调地址格式
|
||||
|
||||
添加服务商时,系统会自动根据站点域名拼接回调地址,格式如下:
|
||||
|
||||
| 服务商 | 回调路径 |
|
||||
|--------|---------|
|
||||
| **EasyPay** | `https://your-domain.com/api/v1/payment/webhook/easypay` |
|
||||
| **支付宝官方** | `https://your-domain.com/api/v1/payment/webhook/alipay` |
|
||||
| **微信官方** | `https://your-domain.com/api/v1/payment/webhook/wxpay` |
|
||||
| **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` |
|
||||
|
||||
> 将 `your-domain.com` 替换为你的实际域名。EasyPay / 支付宝 / 微信的回调地址在添加服务商时自动填入,无需手动配置。
|
||||
|
||||
### Stripe Webhook 设置
|
||||
|
||||
1. 登录 [Stripe Dashboard](https://dashboard.stripe.com/)
|
||||
2. 进入 **Developers → Webhooks**
|
||||
3. 添加端点,填写回调地址
|
||||
4. 订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
|
||||
5. 将生成的 Webhook Secret(`whsec_...`)填入服务商配置
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 回调地址必须是 **HTTPS**(Stripe 强制要求,其他服务商强烈推荐)
|
||||
- 确保服务器防火墙允许支付平台的回调请求
|
||||
- 系统会自动进行签名验证,防止伪造回调
|
||||
- 支付成功后自动完成余额充值,无需人工干预
|
||||
|
||||
---
|
||||
|
||||
## 支付流程
|
||||
|
||||
```
|
||||
用户选择充值金额和支付方式
|
||||
│
|
||||
▼
|
||||
创建订单 (PENDING)
|
||||
├─ 校验金额范围、待支付订单数、每日限额
|
||||
├─ 负载均衡选择服务商实例
|
||||
└─ 调用服务商获取支付信息
|
||||
│
|
||||
▼
|
||||
用户完成支付
|
||||
├─ EasyPay → 扫码 / H5 跳转
|
||||
├─ 支付宝官方 → 桌面二维码 / 移动端支付宝跳转
|
||||
├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI
|
||||
└─ Stripe → Payment Element(银行卡/支付宝/微信等)
|
||||
│
|
||||
▼
|
||||
支付回调验签 → 订单 PAID
|
||||
│
|
||||
▼
|
||||
自动充值到用户余额 → 订单 COMPLETED
|
||||
```
|
||||
|
||||
### 订单状态说明
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `PENDING` | 待支付,等待用户完成支付 |
|
||||
| `PAID` | 已支付,等待充值到账 |
|
||||
| `COMPLETED` | 已完成,余额已到账 |
|
||||
| `EXPIRED` | 已过期,超时未支付 |
|
||||
| `CANCELLED` | 已取消,用户主动取消 |
|
||||
| `FAILED` | 充值失败,可管理员重试 |
|
||||
| `REFUND_REQUESTED` | 已申请退款 |
|
||||
| `REFUNDING` | 退款处理中 |
|
||||
| `REFUNDED` | 已退款 |
|
||||
|
||||
### 超时与兜底
|
||||
|
||||
- 订单超时后,后台任务会先查询上游支付状态再标记过期
|
||||
- 如果用户实际已支付但回调延迟,系统会通过查询补单
|
||||
- 后台任务每 60 秒执行一次超时检查
|
||||
|
||||
---
|
||||
|
||||
## 从 Sub2ApiPay 迁移
|
||||
|
||||
如果你之前使用 [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) 作为外部支付系统,现在可以迁移到内置支付:
|
||||
|
||||
### 主要差异
|
||||
|
||||
| 对比项 | Sub2ApiPay | 内置支付 |
|
||||
|--------|-----------|---------|
|
||||
| 部署方式 | 独立服务(Next.js + PostgreSQL) | 内置于 Sub2API,无需额外部署 |
|
||||
| 支付方式 | EasyPay、支付宝、微信、Stripe | 相同 |
|
||||
| 配置方式 | 环境变量 + 独立管理后台 | Sub2API 管理后台内统一配置 |
|
||||
| 充值对接 | 通过 Admin API 回调 | 内部直接处理,更可靠 |
|
||||
| 订阅套餐 | 支持 | 暂不支持(计划中) |
|
||||
| 订单管理 | 独立管理界面 | 集成在 Sub2API 管理后台 |
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
1. 在 Sub2API 管理后台启用支付并配置服务商(使用相同的支付凭证)
|
||||
2. 更新 Webhook 回调地址为 Sub2API 的回调地址
|
||||
3. 确认新订单通过内置支付正常处理
|
||||
4. 停用 Sub2ApiPay 服务
|
||||
|
||||
> **注意**:Sub2ApiPay 中的历史订单数据不会自动迁移。建议保留 Sub2ApiPay 一段时间以便查询历史记录。
|
||||
@@ -55,12 +55,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
||||
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
v-model="testPrompt"
|
||||
:label="t('admin.accounts.geminiImagePromptLabel')"
|
||||
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
|
||||
:hint="t('admin.accounts.geminiImageTestHint')"
|
||||
:label="t('admin.accounts.imagePromptLabel')"
|
||||
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
|
||||
:hint="t('admin.accounts.imageTestHint')"
|
||||
:disabled="status === 'connecting'"
|
||||
rows="3"
|
||||
/>
|
||||
@@ -122,25 +122,49 @@
|
||||
|
||||
<div v-if="generatedImages.length > 0" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.accounts.geminiImagePreview') }}
|
||||
{{ t('admin.accounts.imagePreview') }}
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<a
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<div
|
||||
v-for="(image, index) in generatedImages"
|
||||
:key="`${image.url}-${index}`"
|
||||
:href="image.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
|
||||
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
|
||||
@click="previewImageUrl = image.url"
|
||||
>
|
||||
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
|
||||
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
|
||||
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
|
||||
</div>
|
||||
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||
{{ image.mimeType || 'image/*' }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="previewImageUrl"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||
@click.self="previewImageUrl = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
@click="previewImageUrl = ''"
|
||||
>
|
||||
<Icon name="x" size="lg" :stroke-width="2" />
|
||||
</button>
|
||||
<img
|
||||
:src="previewImageUrl"
|
||||
alt="preview"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Test Info -->
|
||||
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -152,8 +176,8 @@
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
supportsImageTest
|
||||
? t('admin.accounts.imageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
</span>
|
||||
@@ -250,6 +274,7 @@ const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const previewImageUrl = ref('')
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
|
||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||
})
|
||||
|
||||
const supportsOpenAIImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gpt-image-')) return false
|
||||
return props.account?.platform === 'openai'
|
||||
})
|
||||
|
||||
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
|
||||
|
||||
const sortTestModels = (models: ClaudeModel[]) => {
|
||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||
|
||||
@@ -284,8 +317,8 @@ watch(
|
||||
)
|
||||
|
||||
watch(selectedModelId, () => {
|
||||
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
||||
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
||||
if (supportsImageTest.value && !testPrompt.value.trim()) {
|
||||
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -325,6 +358,7 @@ const resetState = () => {
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
previewImageUrl.value = ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -377,7 +411,7 @@ const startTest = async () => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
}),
|
||||
signal: abortController.signal
|
||||
})
|
||||
@@ -444,8 +478,8 @@ const handleEvent = (event: {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(
|
||||
supportsGeminiImageTest.value
|
||||
? t('admin.accounts.sendingGeminiImageRequest')
|
||||
supportsImageTest.value
|
||||
? t('admin.accounts.sendingImageRequest')
|
||||
: t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
@@ -466,7 +500,7 @@ const handleEvent = (event: {
|
||||
url: event.image_url,
|
||||
mimeType: event.mime_type
|
||||
})
|
||||
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||
}
|
||||
break
|
||||
|
||||
@@ -500,3 +534,14 @@ const copyOutput = () => {
|
||||
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -147,6 +147,21 @@
|
||||
<Icon name="cloud" size="sm" />
|
||||
Antigravity
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'kimi'"
|
||||
:class="[
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'kimi'
|
||||
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
Kimi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -729,6 +744,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Kimi - OAuth, API Key, or CLI) -->
|
||||
<div v-if="form.platform === 'kimi'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">OAuth</div>
|
||||
<div class="text-xs text-gray-500">Kimi Code OAuth</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">API Key</div>
|
||||
<div class="text-xs text-gray-500">Kimi API Key</div>
|
||||
</div>
|
||||
</button>
|
||||
<!-- CLI Option -->
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'cli'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'cli'
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'cli'
|
||||
? 'bg-teal-100 text-teal-600 dark:bg-teal-800'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">CLI</div>
|
||||
<div class="text-xs text-gray-500">Local kimi-cli</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
||||
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
||||
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -1477,9 +1575,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
|
||||
<!-- 配额控制 (apikey/bedrock/cli: 配额限制 + 亲和) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic' && (form.type === 'apikey' || form.type === 'bedrock')"
|
||||
v-if="form.type === 'apikey' || form.type === 'bedrock' || form.type === 'cli'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
@@ -1529,58 +1627,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
<div
|
||||
v-else-if="form.type === 'apikey' || form.type === 'bedrock'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaLimitHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<QuotaLimitCard
|
||||
:totalLimit="editQuotaLimit"
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||||
:dailyResetMode="editDailyResetMode"
|
||||
:dailyResetHour="editDailyResetHour"
|
||||
:weeklyResetMode="editWeeklyResetMode"
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
@update:dailyResetMode="editDailyResetMode = $event"
|
||||
@update:dailyResetHour="editDailyResetHour = $event"
|
||||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
|
||||
@@ -2550,7 +2596,145 @@
|
||||
|
||||
<!-- Step 2: OAuth Authorization -->
|
||||
<div v-else class="space-y-5">
|
||||
<!-- Kimi Device Flow -->
|
||||
<div v-if="form.platform === 'kimi'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-teal-200 bg-teal-50 p-4 dark:border-teal-700 dark:bg-teal-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-teal-500"
|
||||
>
|
||||
<Icon name="link" size="md" class="text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-teal-900 dark:text-teal-200">
|
||||
Kimi Code OAuth
|
||||
</h4>
|
||||
|
||||
<!-- Step 1: Initiate device auth -->
|
||||
<div v-if="!kimiOAuth.userCode.value" class="space-y-3">
|
||||
<p class="text-sm text-teal-800 dark:text-teal-300">
|
||||
{{ t('admin.accounts.oauth.kimi.deviceFlowDesc') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="kimiOAuth.loading.value"
|
||||
class="btn btn-primary text-sm"
|
||||
@click="handleKimiDeviceAuth"
|
||||
>
|
||||
<svg
|
||||
v-if="kimiOAuth.loading.value"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="link" size="sm" class="mr-2" />
|
||||
{{
|
||||
kimiOAuth.loading.value
|
||||
? t('admin.accounts.oauth.generating')
|
||||
: t('admin.accounts.oauth.kimi.startDeviceAuth')
|
||||
}}
|
||||
</button>
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-if="kimiOAuth.error.value"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ kimiOAuth.error.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Show user_code and verification_uri -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-teal-300 bg-white/80 p-4 dark:border-teal-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-2 text-sm font-medium text-teal-900 dark:text-teal-200">
|
||||
{{ t('admin.accounts.oauth.kimi.step1OpenLink') }}
|
||||
</p>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-gray-100 px-3 py-2 font-mono text-lg font-bold dark:bg-gray-700"
|
||||
>
|
||||
{{ kimiOAuth.userCode.value }}
|
||||
</code>
|
||||
</div>
|
||||
<a
|
||||
:href="kimiOAuth.verificationUriComplete.value || kimiOAuth.verificationUri.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary inline-flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" />
|
||||
{{ t('admin.accounts.oauth.kimi.openVerificationPage') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-teal-300 bg-white/80 p-4 dark:border-teal-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-2 text-sm font-medium text-teal-900 dark:text-teal-200">
|
||||
{{ t('admin.accounts.oauth.kimi.step2Wait') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-sm text-teal-700 dark:text-teal-300">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.kimi.pollingForToken') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-if="kimiOAuth.error.value"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ kimiOAuth.error.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standard OAuth Flow for other platforms -->
|
||||
<OAuthAuthorizationFlow
|
||||
v-else
|
||||
ref="oauthFlowRef"
|
||||
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
|
||||
:auth-url="currentAuthUrl"
|
||||
@@ -2573,7 +2757,6 @@
|
||||
@validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
|
||||
@validate-session-token="handleValidateSessionToken"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -2889,7 +3072,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ref, reactive, computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
@@ -2912,6 +3095,7 @@ import {
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import { useKimiOAuth } from '@/composables/useKimiOAuth'
|
||||
import type {
|
||||
Proxy,
|
||||
AdminGroup,
|
||||
@@ -2996,12 +3180,14 @@ const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
|
||||
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
|
||||
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
|
||||
const kimiOAuth = useKimiOAuth() // For Kimi OAuth
|
||||
|
||||
// Computed: current OAuth state for template binding
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.authUrl.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
|
||||
if (form.platform === 'kimi') return '' // Device flow doesn't use auth URL
|
||||
return oauth.authUrl.value
|
||||
})
|
||||
|
||||
@@ -3009,6 +3195,7 @@ const currentSessionId = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.sessionId.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
|
||||
if (form.platform === 'kimi') return kimiOAuth.sessionId.value
|
||||
return oauth.sessionId.value
|
||||
})
|
||||
|
||||
@@ -3016,6 +3203,7 @@ const currentOAuthLoading = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.loading.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.loading.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
|
||||
if (form.platform === 'kimi') return kimiOAuth.loading.value
|
||||
return oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -3023,6 +3211,7 @@ const currentOAuthError = computed(() => {
|
||||
if (form.platform === 'openai') return openaiOAuth.error.value
|
||||
if (form.platform === 'gemini') return geminiOAuth.error.value
|
||||
if (form.platform === 'antigravity') return antigravityOAuth.error.value
|
||||
if (form.platform === 'kimi') return kimiOAuth.error.value
|
||||
return oauth.error.value
|
||||
})
|
||||
|
||||
@@ -3045,7 +3234,7 @@ interface TempUnschedRuleForm {
|
||||
// State
|
||||
const step = ref(1)
|
||||
const submitting = ref(false)
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('oauth-based') // UI selection for account category
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'cli'>('oauth-based') // UI selection for account category
|
||||
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
|
||||
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
||||
const apiKeyValue = ref('')
|
||||
@@ -3287,6 +3476,10 @@ const isOAuthFlow = computed(() => {
|
||||
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
return false
|
||||
}
|
||||
// CLI 类型不需要 OAuth 流程
|
||||
if (accountCategory.value === 'cli') {
|
||||
return false
|
||||
}
|
||||
return accountCategory.value === 'oauth-based'
|
||||
})
|
||||
|
||||
@@ -3312,6 +3505,10 @@ const canExchangeCode = computed(() => {
|
||||
if (form.platform === 'antigravity') {
|
||||
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
|
||||
}
|
||||
if (form.platform === 'kimi') {
|
||||
// Device flow: auto-polling handles completion, no manual exchange needed
|
||||
return false
|
||||
}
|
||||
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -3358,6 +3555,11 @@ watch(
|
||||
form.type = 'bedrock' as AccountType
|
||||
return
|
||||
}
|
||||
// CLI 类型
|
||||
if (category === 'cli') {
|
||||
form.type = 'cli'
|
||||
return
|
||||
}
|
||||
if (category === 'oauth-based') {
|
||||
form.type = method as AccountType // 'oauth' or 'setup-token'
|
||||
} else {
|
||||
@@ -3960,6 +4162,42 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// For CLI type, create directly (CLI manages its own auth)
|
||||
if (accountCategory.value === 'cli') {
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
||||
return
|
||||
}
|
||||
|
||||
const credentials: Record<string, unknown> = {}
|
||||
|
||||
// Add model mapping if configured
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
// Add pool mode if enabled
|
||||
if (poolModeEnabled.value) {
|
||||
credentials.pool_mode = true
|
||||
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
|
||||
}
|
||||
|
||||
// Add custom error codes if enabled
|
||||
if (customErrorCodesEnabled.value) {
|
||||
credentials.custom_error_codes_enabled = true
|
||||
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||
}
|
||||
|
||||
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
await createAccountAndFinish(form.platform, 'cli', credentials)
|
||||
return
|
||||
}
|
||||
|
||||
// For Bedrock type, create directly
|
||||
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
if (!form.name.trim()) {
|
||||
@@ -4068,7 +4306,9 @@ const handleSubmit = async () => {
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: form.platform === 'kimi'
|
||||
? 'https://api.kimi.com'
|
||||
: 'https://api.anthropic.com'
|
||||
|
||||
// Build credentials with optional model mapping
|
||||
const credentials: Record<string, unknown> = {
|
||||
@@ -4121,9 +4361,129 @@ const goBackToBasicInfo = () => {
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
kimiOAuth.resetState()
|
||||
stopKimiPolling()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
// Kimi device flow polling
|
||||
let kimiPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stopKimiPolling = () => {
|
||||
if (kimiPollInterval) {
|
||||
clearInterval(kimiPollInterval)
|
||||
kimiPollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleKimiPoll = async () => {
|
||||
const tokenInfo = await kimiOAuth.pollToken()
|
||||
if (tokenInfo === null) {
|
||||
// Still pending, continue polling
|
||||
return
|
||||
}
|
||||
|
||||
// Got token, stop polling
|
||||
stopKimiPolling()
|
||||
|
||||
if (!tokenInfo.access_token) {
|
||||
appStore.showError(t('admin.accounts.oauth.authFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// Build credentials
|
||||
const credentials: Record<string, unknown> = {
|
||||
access_token: tokenInfo.access_token,
|
||||
expires_at: tokenInfo.expires_at
|
||||
}
|
||||
if (tokenInfo.refresh_token) {
|
||||
credentials.refresh_token = tokenInfo.refresh_token
|
||||
}
|
||||
if (tokenInfo.token_type) {
|
||||
credentials.token_type = tokenInfo.token_type
|
||||
}
|
||||
if (tokenInfo.scope) {
|
||||
credentials.scope = tokenInfo.scope
|
||||
}
|
||||
|
||||
// Build extra
|
||||
const extra: Record<string, unknown> = {}
|
||||
|
||||
// Add window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
extra.window_cost_limit = windowCostLimit.value
|
||||
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
}
|
||||
|
||||
// Add session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
extra.max_sessions = maxSessions.value
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add RPM limit settings
|
||||
if (rpmLimitEnabled.value) {
|
||||
const DEFAULT_BASE_RPM = 15
|
||||
extra.base_rpm = (baseRpm.value != null && baseRpm.value > 0)
|
||||
? baseRpm.value
|
||||
: DEFAULT_BASE_RPM
|
||||
extra.rpm_strategy = rpmStrategy.value
|
||||
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||
extra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||
}
|
||||
}
|
||||
|
||||
// UMQ mode
|
||||
if (userMsgQueueMode.value) {
|
||||
extra.user_msg_queue_mode = userMsgQueueMode.value
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
extra.session_id_masking_enabled = true
|
||||
}
|
||||
|
||||
// Add cache TTL override settings
|
||||
if (cacheTTLOverrideEnabled.value) {
|
||||
extra.cache_ttl_override_enabled = true
|
||||
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
|
||||
}
|
||||
|
||||
// Add custom base URL settings
|
||||
if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
|
||||
extra.custom_base_url_enabled = true
|
||||
extra.custom_base_url = customBaseUrl.value.trim()
|
||||
}
|
||||
|
||||
await createAccountAndFinish('kimi', 'oauth', credentials, extra)
|
||||
}
|
||||
|
||||
const handleKimiDeviceAuth = async () => {
|
||||
stopKimiPolling()
|
||||
const success = await kimiOAuth.initiateDeviceAuth(form.proxy_id)
|
||||
if (!success) return
|
||||
|
||||
// Start polling every 5 seconds
|
||||
kimiPollInterval = setInterval(() => {
|
||||
handleKimiPoll().catch((err: any) => {
|
||||
console.error('Kimi poll error:', err)
|
||||
stopKimiPolling()
|
||||
})
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopKimiPolling()
|
||||
})
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
if (form.platform === 'openai') {
|
||||
await openaiOAuth.generateAuthUrl(form.proxy_id)
|
||||
@@ -4136,6 +4496,8 @@ const handleGenerateUrl = async () => {
|
||||
)
|
||||
} else if (form.platform === 'antigravity') {
|
||||
await antigravityOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else if (form.platform === 'kimi') {
|
||||
await handleKimiDeviceAuth()
|
||||
} else {
|
||||
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
|
||||
}
|
||||
@@ -4166,9 +4528,9 @@ const createAccountAndFinish = async (
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
// Inject quota limits for apikey/bedrock accounts
|
||||
// Inject quota limits for apikey/bedrock/cli accounts
|
||||
let finalExtra = extra
|
||||
if (type === 'apikey' || type === 'bedrock') {
|
||||
if (type === 'apikey' || type === 'bedrock' || type === 'cli') {
|
||||
const quotaExtra: Record<string, unknown> = { ...(extra || {}) }
|
||||
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
|
||||
quotaExtra.quota_limit = editQuotaLimit.value
|
||||
@@ -4679,6 +5041,9 @@ const handleExchangeCode = async () => {
|
||||
return handleGeminiExchange(authCode)
|
||||
case 'antigravity':
|
||||
return handleAntigravityExchange(authCode)
|
||||
case 'kimi':
|
||||
// Kimi uses device flow with auto-polling, no manual exchange needed
|
||||
return
|
||||
default:
|
||||
return handleAnthropicExchange(authCode)
|
||||
}
|
||||
|
||||
@@ -407,6 +407,144 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- CLI Account Settings (model restriction, pool mode, etc.) -->
|
||||
<div v-if="account.type === 'cli'" class="space-y-4">
|
||||
<div class="rounded-lg border border-teal-200 bg-teal-50 p-4 dark:border-teal-700 dark:bg-teal-900/30">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="terminal" size="sm" class="mt-0.5 text-teal-600 dark:text-teal-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-teal-900 dark:text-teal-200">Local kimi-cli</p>
|
||||
<p class="text-xs text-teal-700 dark:text-teal-300">This account uses your locally installed kimi-cli. Authentication is managed by the CLI itself.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Restriction Section (same as apikey) -->
|
||||
<div v-if="account.platform !== 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<div
|
||||
v-if="isOpenAIModelRestrictionDisabled"
|
||||
class="mb-3 rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
|
||||
>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
{{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.accounts.whitelistMode') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.accounts.mappingMode') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.allModelsAllowed')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.actualModel')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeModelMapping(index)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addModelMapping"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
+ {{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
|
||||
<!-- Quick Add Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presetMappings"
|
||||
:key="'cli-' + preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Pool Mode -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<PoolModeConfig />
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<CustomErrorCodesConfig />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
<div
|
||||
v-if="account.platform === 'openai' && account.type === 'oauth'"
|
||||
@@ -1173,9 +1311,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
|
||||
<!-- 配额控制 (Anthropic apikey/bedrock/cli: 配额限制 + 亲和) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')"
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock' || account?.type === 'cli')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
@@ -1224,9 +1362,9 @@
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
/>
|
||||
</div>
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock/cli) -->
|
||||
<div
|
||||
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock'"
|
||||
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock' || account?.type === 'cli'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
@@ -2213,8 +2351,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
|
||||
if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
|
||||
// Load quota limit for apikey/bedrock/cli accounts
|
||||
if (newAccount.type === 'apikey' || newAccount.type === 'bedrock' || newAccount.type === 'cli') {
|
||||
const quotaVal = extra?.quota_limit as number | undefined
|
||||
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
|
||||
const dailyVal = extra?.quota_daily_limit as number | undefined
|
||||
@@ -2377,6 +2515,43 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
}
|
||||
} else if (newAccount.type === 'cli' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
|
||||
// Load model mappings and detect mode
|
||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
||||
if (existingMappings && typeof existingMappings === 'object') {
|
||||
const entries = Object.entries(existingMappings)
|
||||
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
|
||||
if (isWhitelistMode) {
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = entries.map(([from]) => from)
|
||||
modelMappings.value = []
|
||||
} else {
|
||||
modelRestrictionMode.value = 'mapping'
|
||||
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
|
||||
allowedModels.value = []
|
||||
}
|
||||
} else {
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
}
|
||||
|
||||
// Load pool mode
|
||||
poolModeEnabled.value = credentials.pool_mode === true
|
||||
poolModeRetryCount.value = normalizePoolModeRetryCount(
|
||||
Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
|
||||
)
|
||||
|
||||
// Load custom error codes
|
||||
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
|
||||
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
|
||||
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
|
||||
selectedErrorCodes.value = [...existingErrorCodes]
|
||||
} else {
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
||||
@@ -2954,6 +3129,48 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
} else if (props.account.type === 'cli') {
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
// Add model mapping if configured
|
||||
const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value)
|
||||
if (shouldApplyModelMapping) {
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
newCredentials.model_mapping = modelMapping
|
||||
} else {
|
||||
delete newCredentials.model_mapping
|
||||
}
|
||||
} else if (currentCredentials.model_mapping) {
|
||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||
}
|
||||
|
||||
// Add pool mode if enabled
|
||||
if (poolModeEnabled.value) {
|
||||
newCredentials.pool_mode = true
|
||||
newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
|
||||
} else {
|
||||
delete newCredentials.pool_mode
|
||||
delete newCredentials.pool_mode_retry_count
|
||||
}
|
||||
|
||||
// Add custom error codes if enabled
|
||||
if (customErrorCodesEnabled.value) {
|
||||
newCredentials.custom_error_codes_enabled = true
|
||||
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||
} else {
|
||||
delete newCredentials.custom_error_codes_enabled
|
||||
delete newCredentials.custom_error_codes
|
||||
}
|
||||
|
||||
// Add intercept warmup requests setting
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
} else if (props.account.type === 'bedrock') {
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
@@ -3223,8 +3440,8 @@ const handleSubmit = async () => {
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// For apikey/bedrock accounts, handle quota_limit in extra
|
||||
if (props.account.type === 'apikey' || props.account.type === 'bedrock') {
|
||||
// For apikey/bedrock/cli accounts, handle quota_limit in extra
|
||||
if (props.account.type === 'apikey' || props.account.type === 'bedrock' || props.account.type === 'cli') {
|
||||
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
|
||||
(props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
|
||||
@@ -25,8 +25,8 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'kimi', label: 'Kimi' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }, { value: 'cli', label: t('admin.accounts.cliType') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
||||
const privacyOpts = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPrivacyModes') },
|
||||
|
||||
@@ -55,12 +55,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
||||
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||
<TextArea
|
||||
v-model="testPrompt"
|
||||
:label="t('admin.accounts.geminiImagePromptLabel')"
|
||||
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
|
||||
:hint="t('admin.accounts.geminiImageTestHint')"
|
||||
:label="t('admin.accounts.imagePromptLabel')"
|
||||
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
|
||||
:hint="t('admin.accounts.imageTestHint')"
|
||||
:disabled="status === 'connecting'"
|
||||
rows="3"
|
||||
/>
|
||||
@@ -122,25 +122,49 @@
|
||||
|
||||
<div v-if="generatedImages.length > 0" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.accounts.geminiImagePreview') }}
|
||||
{{ t('admin.accounts.imagePreview') }}
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<a
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<div
|
||||
v-for="(image, index) in generatedImages"
|
||||
:key="`${image.url}-${index}`"
|
||||
:href="image.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
|
||||
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
|
||||
@click="previewImageUrl = image.url"
|
||||
>
|
||||
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
|
||||
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
|
||||
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
|
||||
</div>
|
||||
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||
{{ image.mimeType || 'image/*' }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="previewImageUrl"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||
@click.self="previewImageUrl = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
@click="previewImageUrl = ''"
|
||||
>
|
||||
<Icon name="x" size="lg" :stroke-width="2" />
|
||||
</button>
|
||||
<img
|
||||
:src="previewImageUrl"
|
||||
alt="preview"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Test Info -->
|
||||
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -152,8 +176,8 @@
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
supportsImageTest
|
||||
? t('admin.accounts.imageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
</span>
|
||||
@@ -250,6 +274,7 @@ const testPrompt = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const generatedImages = ref<PreviewImage[]>([])
|
||||
const previewImageUrl = ref('')
|
||||
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
|
||||
const supportsGeminiImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
|
||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||
})
|
||||
|
||||
const supportsOpenAIImageTest = computed(() => {
|
||||
const modelID = selectedModelId.value.toLowerCase()
|
||||
if (!modelID.startsWith('gpt-image-')) return false
|
||||
return props.account?.platform === 'openai'
|
||||
})
|
||||
|
||||
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
|
||||
|
||||
const sortTestModels = (models: ClaudeModel[]) => {
|
||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||
|
||||
@@ -284,8 +317,8 @@ watch(
|
||||
)
|
||||
|
||||
watch(selectedModelId, () => {
|
||||
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
||||
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
||||
if (supportsImageTest.value && !testPrompt.value.trim()) {
|
||||
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -325,6 +358,7 @@ const resetState = () => {
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
previewImageUrl.value = ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -377,7 +411,7 @@ const startTest = async () => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model_id: selectedModelId.value,
|
||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
||||
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||
}),
|
||||
signal: abortController.signal
|
||||
})
|
||||
@@ -444,8 +478,8 @@ const handleEvent = (event: {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(
|
||||
supportsGeminiImageTest.value
|
||||
? t('admin.accounts.sendingGeminiImageRequest')
|
||||
supportsImageTest.value
|
||||
? t('admin.accounts.sendingImageRequest')
|
||||
: t('admin.accounts.sendingTestMessage'),
|
||||
'text-gray-400'
|
||||
)
|
||||
@@ -466,7 +500,7 @@ const handleEvent = (event: {
|
||||
url: event.image_url,
|
||||
mimeType: event.mime_type
|
||||
})
|
||||
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||
}
|
||||
break
|
||||
|
||||
@@ -500,3 +534,14 @@ const copyOutput = () => {
|
||||
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,13 +24,13 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
const messages: Record<string, string> = {
|
||||
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
|
||||
'admin.accounts.imagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
|
||||
if (key === 'admin.accounts.imageReceived' && params?.count) {
|
||||
return `received-${params.count}`
|
||||
}
|
||||
return messages[key] || key
|
||||
@@ -140,7 +140,7 @@ describe('AccountTestModal', () => {
|
||||
prompt: 'draw a tiny orange cat astronaut'
|
||||
})
|
||||
|
||||
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
|
||||
const preview = wrapper.find('img[alt="test-image-1"]')
|
||||
expect(preview.exists()).toBe(true)
|
||||
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
</svg>
|
||||
<!-- Setup Token icon -->
|
||||
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
|
||||
<!-- CLI icon -->
|
||||
<Icon v-else-if="type === 'cli'" name="terminal" size="xs" />
|
||||
<!-- API Key icon -->
|
||||
<Icon v-else name="key" size="xs" />
|
||||
<span>{{ typeLabel }}</span>
|
||||
@@ -75,6 +77,7 @@ const platformLabel = computed(() => {
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
if (props.platform === 'antigravity') return 'Antigravity'
|
||||
if (props.platform === 'kimi') return 'Kimi'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
@@ -88,6 +91,8 @@ const typeLabel = computed(() => {
|
||||
return 'Key'
|
||||
case 'bedrock':
|
||||
return 'AWS'
|
||||
case 'cli':
|
||||
return 'CLI'
|
||||
default:
|
||||
return props.type
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return 'codex'
|
||||
case 'kimi':
|
||||
return 'codex'
|
||||
case 'gemini':
|
||||
return 'gemini'
|
||||
case 'antigravity':
|
||||
@@ -266,7 +268,8 @@ const SparkleIcon = {
|
||||
const clientTabs = computed((): TabConfig[] => {
|
||||
if (!props.platform) return []
|
||||
switch (props.platform) {
|
||||
case 'openai': {
|
||||
case 'openai':
|
||||
case 'kimi': {
|
||||
const tabs: TabConfig[] = [
|
||||
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
|
||||
{ id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon },
|
||||
@@ -322,6 +325,7 @@ const currentTabs = computed(() => {
|
||||
const platformDescription = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
case 'kimi':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.description')
|
||||
}
|
||||
@@ -338,6 +342,7 @@ const platformDescription = computed(() => {
|
||||
const platformNote = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
case 'kimi':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return t('keys.useKeyModal.note')
|
||||
}
|
||||
@@ -399,6 +404,7 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
case 'anthropic':
|
||||
return [generateOpenCodeConfig('anthropic', apiBase, apiKey)]
|
||||
case 'openai':
|
||||
case 'kimi':
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
case 'gemini':
|
||||
return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
|
||||
@@ -414,6 +420,7 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
case 'kimi':
|
||||
if (activeClientTab.value === 'claude') {
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
|
||||
@@ -67,23 +67,23 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="item.details && (item.details.display_name || item.details.subject_hint || bindingCountLabel(item.details) || item.details.note)"
|
||||
v-if="hasBindingDetails(item.provider, item.details)"
|
||||
class="grid gap-1 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<p
|
||||
v-if="item.details.display_name"
|
||||
v-if="item.provider !== 'email' && item.details?.display_name"
|
||||
class="font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ item.details.display_name }}
|
||||
</p>
|
||||
<p v-if="item.details.subject_hint">
|
||||
<p v-if="item.provider !== 'email' && item.details?.subject_hint">
|
||||
{{ item.details.subject_hint }}
|
||||
</p>
|
||||
<p v-if="bindingCountLabel(item.details)">
|
||||
{{ bindingCountLabel(item.details) }}
|
||||
</p>
|
||||
<p v-if="item.details.note">
|
||||
{{ item.details.note }}
|
||||
<p v-if="bindingNote(item.details)">
|
||||
{{ bindingNote(item.details) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +298,13 @@ const emailSubmitActionLabel = computed(() =>
|
||||
? t('profile.authBindings.confirmEmailReplaceAction')
|
||||
: t('profile.authBindings.confirmEmailBindAction')
|
||||
)
|
||||
const legacyBindingNoteKeys: Record<string, string> = {
|
||||
'Primary account email is managed from the profile form.':
|
||||
'profile.authBindings.notes.emailManagedFromProfile',
|
||||
'You can unbind this sign-in method.': 'profile.authBindings.notes.canUnbind',
|
||||
'Bind another sign-in method before unbinding.':
|
||||
'profile.authBindings.notes.bindAnotherBeforeUnbind',
|
||||
}
|
||||
|
||||
function resolveLegacyCompatibleWeChatSettings(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined
|
||||
@@ -489,6 +496,36 @@ function bindingCountLabel(details: UserAuthBindingStatus | null): string {
|
||||
return t('profile.authBindings.boundCount', { count: details.bound_count })
|
||||
}
|
||||
|
||||
function bindingNote(details: UserAuthBindingStatus | null): string {
|
||||
if (!details) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const noteKey = details.note_key?.trim() || legacyBindingNoteKeys[details.note?.trim() || ''] || ''
|
||||
if (noteKey) {
|
||||
const translated = t(noteKey)
|
||||
if (translated !== noteKey) {
|
||||
return translated
|
||||
}
|
||||
}
|
||||
|
||||
return details.note?.trim() || ''
|
||||
}
|
||||
|
||||
function hasBindingDetails(
|
||||
provider: UserAuthProvider,
|
||||
details: UserAuthBindingStatus | null
|
||||
): boolean {
|
||||
if (!details) {
|
||||
return false
|
||||
}
|
||||
|
||||
const showsProviderIdentityDetails =
|
||||
provider !== 'email' && Boolean(details.display_name || details.subject_hint)
|
||||
|
||||
return Boolean(showsProviderIdentityDetails || bindingCountLabel(details) || bindingNote(details))
|
||||
}
|
||||
|
||||
function toggleEmailForm(): void {
|
||||
isEmailFormExpanded.value = !isEmailFormExpanded.value
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
if (key === 'profile.authBindings.codeSentTo') return `Code sent to ${params?.email || ''}`.trim()
|
||||
if (key === 'profile.authBindings.bindSuccess') return 'Bind success'
|
||||
if (key === 'profile.authBindings.replaceSuccess') return 'Primary email updated'
|
||||
if (key === 'profile.authBindings.notes.emailManagedFromProfile')
|
||||
return 'Primary email is managed in the profile form'
|
||||
if (key === 'profile.authBindings.notes.canUnbind')
|
||||
return 'You can unbind this sign-in method'
|
||||
if (key === 'profile.authBindings.notes.bindAnotherBeforeUnbind')
|
||||
return 'Bind another sign-in method before unbinding'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
@@ -164,7 +170,7 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
|
||||
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
|
||||
expect(locationState.current.href).toContain('mode=open')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
@@ -219,7 +225,7 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
|
||||
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
|
||||
expect(locationState.current.href).toContain('mode=open')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
@@ -401,6 +407,36 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
|
||||
})
|
||||
|
||||
it('shows the bound email only once and localizes the email management note', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
props: {
|
||||
user: createUser({
|
||||
email: 'alice@example.com',
|
||||
email_bound: true,
|
||||
auth_bindings: {
|
||||
email: {
|
||||
bound: true,
|
||||
display_name: 'alice@example.com',
|
||||
subject_hint: 'a***e@example.com',
|
||||
note_key: 'profile.authBindings.notes.emailManagedFromProfile',
|
||||
note: 'Primary account email is managed from the profile form.',
|
||||
} as any,
|
||||
},
|
||||
}),
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
wechatEnabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text().match(/alice@example\.com/g)).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('a***e@example.com')
|
||||
expect(wrapper.text()).toContain('Primary email is managed in the profile form')
|
||||
})
|
||||
|
||||
it('keeps the email form available for replacing a bound primary email', async () => {
|
||||
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
|
||||
userApiMocks.bindEmailIdentity.mockResolvedValue(
|
||||
@@ -541,6 +577,36 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound')
|
||||
})
|
||||
|
||||
it('localizes third-party unbind guidance from note_key', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
props: {
|
||||
user: createUser({
|
||||
email_bound: true,
|
||||
linuxdo_bound: true,
|
||||
auth_bindings: {
|
||||
email: { bound: true },
|
||||
linuxdo: {
|
||||
bound: true,
|
||||
display_name: 'linuxdo-handle',
|
||||
note_key: 'profile.authBindings.notes.canUnbind',
|
||||
note: 'You can unbind this sign-in method.',
|
||||
can_unbind: true,
|
||||
} as any,
|
||||
},
|
||||
}),
|
||||
linuxdoEnabled: true,
|
||||
oidcEnabled: false,
|
||||
wechatEnabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('You can unbind this sign-in method')
|
||||
expect(wrapper.text()).not.toContain('You can unbind this sign-in method.')
|
||||
})
|
||||
|
||||
it('hides bind actions when provider details say bindable but the provider is disabled', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
|
||||
196
frontend/src/composables/useKimiOAuth.ts
Normal file
196
frontend/src/composables/useKimiOAuth.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
export interface KimiTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
expires_at?: number
|
||||
scope?: string
|
||||
token_type?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useKimiOAuth() {
|
||||
const appStore = useAppStore()
|
||||
const endpointPrefix = '/admin/kimi'
|
||||
|
||||
// State
|
||||
const deviceCode = ref('')
|
||||
const userCode = ref('')
|
||||
const verificationUri = ref('')
|
||||
const verificationUriComplete = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sessionId = ref('') // reuse for device_code
|
||||
|
||||
// Reset state
|
||||
const resetState = () => {
|
||||
deviceCode.value = ''
|
||||
userCode.value = ''
|
||||
verificationUri.value = ''
|
||||
verificationUriComplete.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
sessionId.value = ''
|
||||
}
|
||||
|
||||
// Initiate device authorization
|
||||
const initiateDeviceAuth = async (
|
||||
proxyId?: number | null
|
||||
): Promise<boolean> => {
|
||||
loading.value = true
|
||||
deviceCode.value = ''
|
||||
userCode.value = ''
|
||||
verificationUri.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<{
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}>(`${endpointPrefix}/oauth/device-auth`, payload)
|
||||
|
||||
deviceCode.value = data.device_code
|
||||
userCode.value = data.user_code
|
||||
verificationUri.value = data.verification_uri
|
||||
verificationUriComplete.value = data.verification_uri_complete
|
||||
sessionId.value = data.device_code
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to initiate Kimi device auth'
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for token
|
||||
const pollToken = async (): Promise<KimiTokenInfo | null> => {
|
||||
if (!deviceCode.value) {
|
||||
error.value = 'No device code available'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<{
|
||||
status: string
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
expires_at?: number
|
||||
scope?: string
|
||||
token_type?: string
|
||||
message?: string
|
||||
}>(`${endpointPrefix}/oauth/token`, {
|
||||
device_code: deviceCode.value
|
||||
})
|
||||
|
||||
if (data.status === 'pending') {
|
||||
return null // Still pending
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
scope: data.scope,
|
||||
token_type: data.token_type
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to poll Kimi token'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build credentials for Kimi OAuth account
|
||||
const buildCredentials = (tokenInfo: KimiTokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
access_token: tokenInfo.access_token,
|
||||
expires_at: tokenInfo.expires_at
|
||||
}
|
||||
if (tokenInfo.refresh_token) {
|
||||
creds.refresh_token = tokenInfo.refresh_token
|
||||
}
|
||||
if (tokenInfo.token_type) {
|
||||
creds.token_type = tokenInfo.token_type
|
||||
}
|
||||
if (tokenInfo.scope) {
|
||||
creds.scope = tokenInfo.scope
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
// Build extra info from token response
|
||||
const buildExtraInfo = (_tokenInfo: KimiTokenInfo): Record<string, string> | undefined => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Import local credentials from kimi-cli
|
||||
const importLocalCredentials = async (
|
||||
credentialsJson: string
|
||||
): Promise<KimiTokenInfo | null> => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_at: number
|
||||
scope?: string
|
||||
token_type?: string
|
||||
}>(`${endpointPrefix}/oauth/import-local`, {
|
||||
credentials_json: credentialsJson
|
||||
})
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: data.expires_at,
|
||||
scope: data.scope,
|
||||
token_type: data.token_type
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to import local credentials'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUri,
|
||||
verificationUriComplete,
|
||||
loading,
|
||||
error,
|
||||
sessionId,
|
||||
resetState,
|
||||
initiateDeviceAuth,
|
||||
pollToken,
|
||||
buildCredentials,
|
||||
buildExtraInfo,
|
||||
importLocalCredentials
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ const openaiModels = [
|
||||
// GPT-5.3 系列
|
||||
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
|
||||
'chatgpt-4o-latest',
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
|
||||
// GPT Image 系列
|
||||
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
|
||||
]
|
||||
|
||||
// Anthropic Claude
|
||||
|
||||
@@ -1042,6 +1042,11 @@ export default {
|
||||
oidc: '{providerName}',
|
||||
wechat: 'WeChat',
|
||||
},
|
||||
notes: {
|
||||
emailManagedFromProfile: 'Primary email is managed in the profile form',
|
||||
canUnbind: 'You can unbind this sign-in method',
|
||||
bindAnotherBeforeUnbind: 'Bind another sign-in method before unbinding',
|
||||
},
|
||||
source: {
|
||||
avatar: 'Avatar is currently synced from {providerName}',
|
||||
username: 'Nickname is currently synced from {providerName}',
|
||||
@@ -2200,6 +2205,7 @@ export default {
|
||||
oauthType: 'OAuth',
|
||||
setupToken: 'Setup Token',
|
||||
apiKey: 'API Key',
|
||||
cliType: 'CLI',
|
||||
// Schedulable toggle
|
||||
schedulable: 'Schedulable',
|
||||
schedulableHint: 'Enable to include this account in API request scheduling',
|
||||
@@ -2867,6 +2873,15 @@ export default {
|
||||
validateAndCreate: 'Validate & Create',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
failedToValidateRT: 'Failed to validate Refresh Token'
|
||||
},
|
||||
// Kimi specific
|
||||
kimi: {
|
||||
deviceFlowDesc: 'Click the button below to start the device authorization flow. You will receive a user code to enter on the Kimi verification page.',
|
||||
startDeviceAuth: 'Start Device Auth',
|
||||
step1OpenLink: '1. Open the link below and enter the user code:',
|
||||
openVerificationPage: 'Open Verification Page',
|
||||
step2Wait: '2. Waiting for authorization...',
|
||||
pollingForToken: 'Polling for token...'
|
||||
}
|
||||
}, // Gemini specific (platform-wide)
|
||||
gemini: {
|
||||
@@ -3023,7 +3038,7 @@ export default {
|
||||
connectedToApi: 'Connected to API',
|
||||
usingModel: 'Using model: {model}',
|
||||
sendingTestMessage: 'Sending test message: "hi"',
|
||||
sendingGeminiImageRequest: 'Sending Gemini image generation test request...',
|
||||
sendingImageRequest: 'Sending image generation test request...',
|
||||
response: 'Response:',
|
||||
startTest: 'Start Test',
|
||||
testing: 'Testing...',
|
||||
@@ -3035,13 +3050,13 @@ export default {
|
||||
selectTestModel: 'Select Test Model',
|
||||
testModel: 'Test model',
|
||||
testPrompt: 'Prompt: "hi"',
|
||||
geminiImagePromptLabel: 'Image prompt',
|
||||
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
||||
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||
geminiImageTestMode: 'Mode: Gemini image generation test',
|
||||
geminiImagePreview: 'Generated images:',
|
||||
geminiImageReceived: 'Received test image #{count}',
|
||||
imagePromptLabel: 'Image prompt',
|
||||
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
||||
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||
imageTestMode: 'Mode: Image generation test',
|
||||
imagePreview: 'Generated images:',
|
||||
imageReceived: 'Received test image #{count}',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
|
||||
@@ -1046,6 +1046,11 @@ export default {
|
||||
oidc: '{providerName}',
|
||||
wechat: '微信',
|
||||
},
|
||||
notes: {
|
||||
emailManagedFromProfile: '主邮箱在资料表单中管理',
|
||||
canUnbind: '你可以解绑这个登录方式。',
|
||||
bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。',
|
||||
},
|
||||
source: {
|
||||
avatar: '头像当前来自 {providerName}',
|
||||
username: '昵称当前来自 {providerName}',
|
||||
@@ -2279,6 +2284,7 @@ export default {
|
||||
allGroups: '全部分组',
|
||||
ungroupedGroup: '未分配分组',
|
||||
oauthType: 'OAuth',
|
||||
cliType: 'CLI',
|
||||
// Schedulable toggle
|
||||
schedulable: '参与调度',
|
||||
schedulableHint: '开启后账号参与API请求调度',
|
||||
@@ -3000,6 +3006,15 @@ export default {
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
failedToValidateRT: '验证 Refresh Token 失败'
|
||||
},
|
||||
// Kimi specific
|
||||
kimi: {
|
||||
deviceFlowDesc: '点击下方按钮启动设备授权流程。您将收到一个用户码,需要在 Kimi 验证页面输入。',
|
||||
startDeviceAuth: '启动设备授权',
|
||||
step1OpenLink: '1. 打开下方链接并输入用户码:',
|
||||
openVerificationPage: '打开验证页面',
|
||||
step2Wait: '2. 等待授权完成...',
|
||||
pollingForToken: '正在轮询令牌...'
|
||||
}
|
||||
},
|
||||
// Gemini specific (platform-wide)
|
||||
@@ -3154,7 +3169,7 @@ export default {
|
||||
connectedToApi: '已连接到 API',
|
||||
usingModel: '使用模型:{model}',
|
||||
sendingTestMessage: '发送测试消息:"hi"',
|
||||
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
|
||||
sendingImageRequest: '发送生图测试请求...',
|
||||
response: '响应:',
|
||||
startTest: '开始测试',
|
||||
retry: '重试',
|
||||
@@ -3165,13 +3180,13 @@ export default {
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
geminiImagePromptLabel: '生图提示词',
|
||||
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||
geminiImageTestMode: '模式:Gemini 生图测试',
|
||||
geminiImagePreview: '生成结果:',
|
||||
geminiImageReceived: '已收到第 {count} 张测试图片',
|
||||
imagePromptLabel: '生图提示词',
|
||||
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||
imageTestMode: '模式:生图测试',
|
||||
imagePreview: '生成结果:',
|
||||
imageReceived: '已收到第 {count} 张测试图片',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UserAuthBindingStatus {
|
||||
bind_start_path?: string | null
|
||||
can_bind?: boolean
|
||||
can_unbind?: boolean
|
||||
note_key?: string | null
|
||||
note?: string | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
@@ -435,7 +436,7 @@ export interface PaginationConfig {
|
||||
|
||||
// ==================== API Key & Group Types ====================
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'kimi'
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription'
|
||||
|
||||
@@ -608,8 +609,8 @@ export interface UpdateGroupRequest {
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
|
||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'kimi'
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'cli'
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* instead of defining their own color mappings.
|
||||
*/
|
||||
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
|
||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'kimi'
|
||||
|
||||
// ── Badge (bg + text + border, for inline badges with border) ───────
|
||||
const BADGE: Record<Platform, string> = {
|
||||
@@ -13,6 +13,7 @@ const BADGE: Record<Platform, string> = {
|
||||
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
||||
kimi: 'bg-violet-500/10 text-violet-600 border-violet-500/30 dark:text-violet-400',
|
||||
}
|
||||
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
||||
|
||||
@@ -22,6 +23,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
|
||||
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
||||
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
||||
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
||||
kimi: 'bg-violet-500/10 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300',
|
||||
}
|
||||
|
||||
// ── Border ──────────────────────────────────────────────────────────
|
||||
@@ -30,6 +32,7 @@ const BORDER: Record<Platform, string> = {
|
||||
openai: 'border-green-500/20 dark:border-green-500/20',
|
||||
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
||||
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
||||
kimi: 'border-violet-500/20 dark:border-violet-500/20',
|
||||
}
|
||||
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
||||
|
||||
@@ -39,6 +42,7 @@ const ACCENT_BAR: Record<Platform, string> = {
|
||||
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
||||
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
||||
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||
kimi: 'bg-gradient-to-r from-violet-400 to-violet-500',
|
||||
}
|
||||
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||
|
||||
@@ -48,6 +52,7 @@ const TEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-600 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-600 dark:text-purple-400',
|
||||
gemini: 'text-blue-600 dark:text-blue-400',
|
||||
kimi: 'text-violet-600 dark:text-violet-400',
|
||||
}
|
||||
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
||||
|
||||
@@ -57,6 +62,7 @@ const ICON: Record<Platform, string> = {
|
||||
openai: 'text-emerald-500 dark:text-emerald-400',
|
||||
antigravity: 'text-purple-500 dark:text-purple-400',
|
||||
gemini: 'text-blue-500 dark:text-blue-400',
|
||||
kimi: 'text-violet-500 dark:text-violet-400',
|
||||
}
|
||||
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
||||
|
||||
@@ -66,6 +72,7 @@ const BUTTON: Record<Platform, string> = {
|
||||
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
||||
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
||||
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
||||
kimi: 'bg-violet-500 text-white hover:bg-violet-600 active:bg-violet-700 dark:bg-violet-500/80 dark:hover:bg-violet-500',
|
||||
}
|
||||
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||
|
||||
@@ -75,6 +82,7 @@ const DISCOUNT: Record<Platform, string> = {
|
||||
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
kimi: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
}
|
||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
|
||||
@@ -84,6 +92,7 @@ const GRADIENT: Record<Platform, string> = {
|
||||
openai: 'from-emerald-500 to-emerald-600',
|
||||
antigravity: 'from-purple-500 to-purple-600',
|
||||
gemini: 'from-blue-500 to-blue-600',
|
||||
kimi: 'from-violet-500 to-violet-600',
|
||||
}
|
||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||
|
||||
@@ -93,6 +102,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-100',
|
||||
antigravity: 'text-purple-100',
|
||||
gemini: 'text-blue-100',
|
||||
kimi: 'text-violet-100',
|
||||
}
|
||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||
|
||||
@@ -101,13 +111,14 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
||||
openai: 'text-emerald-200',
|
||||
antigravity: 'text-purple-200',
|
||||
gemini: 'text-blue-200',
|
||||
kimi: 'text-violet-200',
|
||||
}
|
||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
function isPlatform(p: string): p is Platform {
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
|
||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'kimi'
|
||||
}
|
||||
|
||||
export function platformBadgeClass(p: string): string {
|
||||
@@ -160,6 +171,7 @@ export function platformLabel(p: string): string {
|
||||
case 'openai': return 'OpenAI'
|
||||
case 'antigravity': return 'Antigravity'
|
||||
case 'gemini': return 'Gemini'
|
||||
case 'kimi': return 'Kimi'
|
||||
default: return p || 'API'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,7 +718,7 @@ const form = reactive({
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// ── Platform config ──
|
||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'kimi']
|
||||
|
||||
// ── Helpers ──
|
||||
function formatDate(value: string): string {
|
||||
|
||||
@@ -628,11 +628,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
|
||||
<!-- 图片生成计费配置 -->
|
||||
<div
|
||||
v-if="
|
||||
createForm.platform === 'antigravity' ||
|
||||
createForm.platform === 'gemini'
|
||||
createForm.platform === 'gemini' ||
|
||||
createForm.platform === 'openai'
|
||||
"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
@@ -1750,11 +1751,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
|
||||
<!-- 图片生成计费配置 -->
|
||||
<div
|
||||
v-if="
|
||||
editForm.platform === 'antigravity' ||
|
||||
editForm.platform === 'gemini'
|
||||
editForm.platform === 'gemini' ||
|
||||
editForm.platform === 'openai'
|
||||
"
|
||||
class="border-t pt-4"
|
||||
>
|
||||
@@ -2781,6 +2783,7 @@ const platformOptions = computed(() => [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "antigravity", label: "Antigravity" },
|
||||
{ value: "kimi", label: "Kimi" },
|
||||
]);
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
@@ -2789,6 +2792,7 @@ const platformFilterOptions = computed(() => [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "antigravity", label: "Antigravity" },
|
||||
{ value: "kimi", label: "Kimi" },
|
||||
]);
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
|
||||
@@ -4136,7 +4136,7 @@
|
||||
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
|
||||
<a
|
||||
:href="paymentGuideHref"
|
||||
:href="paymentMethodsHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@@ -4723,8 +4723,14 @@ function localText(zh: string, en: string): string {
|
||||
|
||||
const paymentGuideHref = computed(() =>
|
||||
locale.value.startsWith("zh")
|
||||
? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98"
|
||||
: "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment",
|
||||
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md"
|
||||
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md",
|
||||
);
|
||||
|
||||
const paymentMethodsHref = computed(() =>
|
||||
locale.value.startsWith("zh")
|
||||
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式"
|
||||
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods",
|
||||
);
|
||||
|
||||
type SettingsTab =
|
||||
|
||||
@@ -508,13 +508,13 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
|
||||
expect(paymentLinks).toHaveLength(2);
|
||||
expect(paymentLinks[0]?.attributes("href")).toBe(
|
||||
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
|
||||
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md",
|
||||
);
|
||||
expect(paymentLinks[1]?.attributes("href")).toBe(
|
||||
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
|
||||
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式",
|
||||
);
|
||||
for (const link of paymentLinks) {
|
||||
expect(link.attributes("href")).not.toContain("docs/PAYMENT");
|
||||
expect(link.attributes("href")).toContain("docs/PAYMENT");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user