Compare commits

...

15 Commits

Author SHA1 Message Date
openclaw
88d687715d fix(kimi-cli): isolate context by running CLI in /tmp to prevent session resume
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
2026-04-24 20:24:23 +08:00
openclaw
55ca0ad687 fix(cli): enable group selection, quota billing and custom error codes for CLI accounts
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
- GroupsView: add 'kimi' to platformOptions/platformFilterOptions so users
can create kimi platform groups
- CreateAccountModal: show quota controls for cli type; inject quota limits
on submit; add custom_error_codes in CLI creation flow
- EditAccountModal: show quota controls for cli type; save quota limits on
submit
- Remove redundant duplicate quota control block that caused TS build error

[布偶猫/Opus🐾]
2026-04-24 02:02:29 +08:00
openclaw
e746e82c39 feat(kimi): add Kimi CLI forward mode support
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
- Add AccountTypeCLI domain constant
- Add KimiCLIGateway to forward requests through local kimi-cli binary
- Route CLI accounts in ForwardKimiChatCompletions to cli gateway
- Handle CLI type in GetAccessToken (no token needed)
- Fix Gin oneof binding to accept 'cli' type (Create/Update Account)
- Fix validateDataAccount to accept bedrock and cli types
- Remove unsupported --model arg from kimi-cli invocation
- Frontend: CLI account creation UI with model mapping, pool mode
- Frontend: CLI edit modal support
- Frontend: UseKeyModal shows OpenAI examples for kimi platform
- Add i18n strings for CLI account type

[缅因猫/Codex🐾]
2026-04-24 01:54:59 +08:00
openclaw
af3efbc525 feat(kimi): add Kimi Code OAuth device flow support
Some checks failed
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Backend:
- Add Kimi OAuth service (InitiateDeviceAuth, PollToken, RefreshToken)
- Add Kimi token provider with caching and auto-refresh
- Add Kimi gateway service for OpenAI-compatible chat completions
- Add admin routes for /kimi/oauth/* endpoints
- Wire up all new services
- Add PlatformKimi to error passthrough rule constants
- Add 'kimi' to group handler platform validation tags
- Fix httpclient usage in Kimi OAuth service (use GetClient with opts)
- Add CanRefresh method to KimiTokenRefresher
- Remove duplicate ProvideKimiTokenProvider from wire ProviderSet
- Remove unused imports
- Lower go.mod requirement to 1.26.1 for local toolchain compatibility
- Add verification_uri_complete support for seamless device auth

Frontend:
- Add useKimiOAuth composable for device flow
- Integrate Kimi device flow UI in CreateAccountModal Step 2
- Add kimi to GroupPlatform and AccountPlatform types
- Add i18n translations for device flow (zh/en)
- Fix Icon name lock-closed -> key
- Add kimi platform colors/styles to platformColors.ts
- Add kimi to AccountTableFilters platform options
- Add kimi to ChannelsView platformOrder
- Use verification_uri_complete for auto-filled user_code

[宪宪/Kimi-1.6🐾]
2026-04-23 12:57:28 +08:00
github-actions[bot]
6449da6c8d chore: sync VERSION to 0.1.115 [skip ci] 2026-04-22 12:08:51 +00:00
shaw
755c7d5026 chore: revert README files to 78f691d2 version 2026-04-22 19:55:13 +08:00
Wesley Liddick
1da4bd72df Merge pull request #1802 from IanShaw027/fix/profile-auth-bindings-i18n
fix(profile): 修正邮箱重复显示问题并添加国际化语言支持
2026-04-22 19:49:43 +08:00
IanShaw027
5551349349 fix: clean up profile auth binding notes 2026-04-22 19:11:51 +08:00
shaw
c6d25f69d5 chore: 恢复PAYMENT系列文件 2026-04-22 18:48:40 +08:00
shaw
45065c23d5 fix(ci): run 108a migration before 109 in backfill integration test 2026-04-22 18:36:44 +08:00
Wesley Liddick
ddf80f5ea1 Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation
fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
2026-04-22 18:18:39 +08:00
Wesley Liddick
c048ca80a4 Merge branch 'main' into rebuild/auth-identity-foundation 2026-04-22 18:17:12 +08:00
IanShaw027
22385be515 Merge remote-tracking branch 'upstream/main' into rebuild/auth-identity-foundation
# Conflicts:
#	backend/internal/service/openai_images.go
2026-04-22 18:13:05 +08:00
shaw
4d0483f5b8 feat: 补充gpt生图模型测试功能 2026-04-22 18:12:03 +08:00
shaw
1e0d466002 feat: 补充gpt生图模型测试功能 2026-04-22 18:06:14 +08:00
60 changed files with 4103 additions and 206 deletions

5
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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 构建 |
## 技术栈

View File

@@ -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 で構築 |
## 技術スタック

View File

@@ -1 +1 @@
0.1.114
0.1.115

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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"`

View 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,
})
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 验证规则配置的有效性

View File

@@ -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"},
}

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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")
{

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 有自己的 TokenProvidersetup-token 类型等)直接从账号读取
accessToken := account.GetCredential("access_token")
if accessToken == "" {

View 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
}

View 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
}

View 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)
}

View 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)
}

View File

@@ -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 账号,回退到 basicOAuth 账号)
if requiredCapability == OpenAIImagesCapabilityNative {
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic)
}
return selection, decision, err
}
func (s *OpenAIGatewayService) selectAccountWithScheduler(

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View 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

View 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
View 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
View 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 协议的支付服务商。
| 参数 | 说明 | 必填 |
|------|------|------|
| **商户 IDPID** | 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 一段时间以便查询历史记录。

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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 }

View File

@@ -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') },

View File

@@ -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>

View File

@@ -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')
})

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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: {

View 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
}
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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: '使用统计',

View File

@@ -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'

View File

@@ -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'
}
}

View File

@@ -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 {

View File

@@ -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(() => [

View File

@@ -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 =

View File

@@ -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");
}
});