mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a75d7d238 | ||
|
|
1c3f2810af | ||
|
|
7c48f9a85f | ||
|
|
980a46ffb0 | ||
|
|
824f763a77 | ||
|
|
9b3c96dc68 | ||
|
|
52f2bc69fb | ||
|
|
067cf8b46e | ||
|
|
d2b080e88b | ||
|
|
29e39b96e5 | ||
|
|
08e994ad86 | ||
|
|
bc2244c83f | ||
|
|
26abd19a28 | ||
|
|
c29b50394f | ||
|
|
3d23cc399f | ||
|
|
c1fd1cb44e | ||
|
|
7e13b6d039 | ||
|
|
c993490a82 | ||
|
|
4d91f39474 | ||
|
|
af6bd44d2a | ||
|
|
541ca3bc27 | ||
|
|
7f5d067af2 | ||
|
|
174ea22eeb | ||
|
|
9fc006546c | ||
|
|
b960ec1980 | ||
|
|
60732a2e8c | ||
|
|
9d1064badd | ||
|
|
2b5944fd6c | ||
|
|
43e95dfa45 | ||
|
|
ca82fd6e98 | ||
|
|
115116e8bf | ||
|
|
7947619cc3 |
@@ -169,6 +169,18 @@ Model authenticity: no content intervention or secondary filtering — experienc
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -172,6 +172,18 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -167,6 +167,18 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1 +1 @@
|
||||
0.1.156
|
||||
0.1.158
|
||||
|
||||
@@ -173,10 +173,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
leaderLockCache := repository.NewLeaderLockCache(redisClient)
|
||||
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
||||
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
|
||||
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
|
||||
+15
-1
@@ -43,6 +43,8 @@ type Group struct {
|
||||
IsExclusive bool `json:"is_exclusive,omitempty"`
|
||||
// Status holds the value of the "status" field.
|
||||
Status string `json:"status,omitempty"`
|
||||
// 内部幂等恢复标识,不对 API 暴露
|
||||
DuplicateOperationID *string `json:"duplicate_operation_id,omitempty"`
|
||||
// Platform holds the value of the "platform" field.
|
||||
Platform string `json:"platform,omitempty"`
|
||||
// SubscriptionType holds the value of the "subscription_type" field.
|
||||
@@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -328,6 +330,13 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.Status = value.String
|
||||
}
|
||||
case group.FieldDuplicateOperationID:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field duplicate_operation_id", values[i])
|
||||
} else if value.Valid {
|
||||
_m.DuplicateOperationID = new(string)
|
||||
*_m.DuplicateOperationID = value.String
|
||||
}
|
||||
case group.FieldPlatform:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field platform", values[i])
|
||||
@@ -680,6 +689,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("status=")
|
||||
builder.WriteString(_m.Status)
|
||||
builder.WriteString(", ")
|
||||
if v := _m.DuplicateOperationID; v != nil {
|
||||
builder.WriteString("duplicate_operation_id=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("platform=")
|
||||
builder.WriteString(_m.Platform)
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -40,6 +40,8 @@ const (
|
||||
FieldIsExclusive = "is_exclusive"
|
||||
// FieldStatus holds the string denoting the status field in the database.
|
||||
FieldStatus = "status"
|
||||
// FieldDuplicateOperationID holds the string denoting the duplicate_operation_id field in the database.
|
||||
FieldDuplicateOperationID = "duplicate_operation_id"
|
||||
// FieldPlatform holds the string denoting the platform field in the database.
|
||||
FieldPlatform = "platform"
|
||||
// FieldSubscriptionType holds the string denoting the subscription_type field in the database.
|
||||
@@ -199,6 +201,7 @@ var Columns = []string{
|
||||
FieldPeakRateMultiplier,
|
||||
FieldIsExclusive,
|
||||
FieldStatus,
|
||||
FieldDuplicateOperationID,
|
||||
FieldPlatform,
|
||||
FieldSubscriptionType,
|
||||
FieldDailyLimitUsd,
|
||||
@@ -292,6 +295,8 @@ var (
|
||||
DefaultStatus string
|
||||
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
StatusValidator func(string) error
|
||||
// DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
|
||||
DuplicateOperationIDValidator func(string) error
|
||||
// DefaultPlatform holds the default value on creation for the "platform" field.
|
||||
DefaultPlatform string
|
||||
// PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
@@ -414,6 +419,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldStatus, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByDuplicateOperationID orders the results by the duplicate_operation_id field.
|
||||
func ByDuplicateOperationID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldDuplicateOperationID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPlatform orders the results by the platform field.
|
||||
func ByPlatform(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPlatform, opts...).ToFunc()
|
||||
|
||||
@@ -115,6 +115,11 @@ func Status(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldStatus, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationID applies equality check predicate on the "duplicate_operation_id" field. It's identical to DuplicateOperationIDEQ.
|
||||
func DuplicateOperationID(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ.
|
||||
func Platform(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
|
||||
@@ -840,6 +845,81 @@ func StatusContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldStatus, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDEQ applies the EQ predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNEQ applies the NEQ predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDIn applies the In predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldDuplicateOperationID, vs...))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNotIn applies the NotIn predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldDuplicateOperationID, vs...))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDGT applies the GT predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDGTE applies the GTE predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDLT applies the LT predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDLTE applies the LTE predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDContains applies the Contains predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDHasPrefix applies the HasPrefix predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDHasSuffix applies the HasSuffix predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDIsNil applies the IsNil predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldDuplicateOperationID))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDNotNil applies the NotNil predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldDuplicateOperationID))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDEqualFold applies the EqualFold predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// DuplicateOperationIDContainsFold applies the ContainsFold predicate on the "duplicate_operation_id" field.
|
||||
func DuplicateOperationIDContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldDuplicateOperationID, v))
|
||||
}
|
||||
|
||||
// PlatformEQ applies the EQ predicate on the "platform" field.
|
||||
func PlatformEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPlatform, v))
|
||||
|
||||
@@ -189,6 +189,20 @@ func (_c *GroupCreate) SetNillableStatus(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
|
||||
func (_c *GroupCreate) SetDuplicateOperationID(v string) *GroupCreate {
|
||||
_c.mutation.SetDuplicateOperationID(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableDuplicateOperationID sets the "duplicate_operation_id" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableDuplicateOperationID(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetDuplicateOperationID(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPlatform sets the "platform" field.
|
||||
func (_c *GroupCreate) SetPlatform(v string) *GroupCreate {
|
||||
_c.mutation.SetPlatform(v)
|
||||
@@ -993,6 +1007,11 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.DuplicateOperationID(); ok {
|
||||
if err := group.DuplicateOperationIDValidator(v); err != nil {
|
||||
return &ValidationError{Name: "duplicate_operation_id", err: fmt.Errorf(`ent: validator failed for field "Group.duplicate_operation_id": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.Platform(); !ok {
|
||||
return &ValidationError{Name: "platform", err: errors.New(`ent: missing required field "Group.platform"`)}
|
||||
}
|
||||
@@ -1152,6 +1171,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
_node.Status = value
|
||||
}
|
||||
if value, ok := _c.mutation.DuplicateOperationID(); ok {
|
||||
_spec.SetField(group.FieldDuplicateOperationID, field.TypeString, value)
|
||||
_node.DuplicateOperationID = &value
|
||||
}
|
||||
if value, ok := _c.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
_node.Platform = value
|
||||
@@ -2246,6 +2269,9 @@ func (u *GroupUpsertOne) UpdateNewValues() *GroupUpsertOne {
|
||||
if _, exists := u.create.mutation.CreatedAt(); exists {
|
||||
s.SetIgnore(group.FieldCreatedAt)
|
||||
}
|
||||
if _, exists := u.create.mutation.DuplicateOperationID(); exists {
|
||||
s.SetIgnore(group.FieldDuplicateOperationID)
|
||||
}
|
||||
}))
|
||||
return u
|
||||
}
|
||||
@@ -3366,6 +3392,9 @@ func (u *GroupUpsertBulk) UpdateNewValues() *GroupUpsertBulk {
|
||||
if _, exists := b.mutation.CreatedAt(); exists {
|
||||
s.SetIgnore(group.FieldCreatedAt)
|
||||
}
|
||||
if _, exists := b.mutation.DuplicateOperationID(); exists {
|
||||
s.SetIgnore(group.FieldDuplicateOperationID)
|
||||
}
|
||||
}
|
||||
}))
|
||||
return u
|
||||
|
||||
@@ -1273,6 +1273,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DuplicateOperationIDCleared() {
|
||||
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
}
|
||||
@@ -3063,6 +3066,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(group.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DuplicateOperationIDCleared() {
|
||||
_spec.ClearField(group.FieldDuplicateOperationID, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.Platform(); ok {
|
||||
_spec.SetField(group.FieldPlatform, field.TypeString, value)
|
||||
}
|
||||
|
||||
@@ -845,6 +845,7 @@ var (
|
||||
{Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "is_exclusive", Type: field.TypeBool, Default: false},
|
||||
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
|
||||
{Name: "duplicate_operation_id", Type: field.TypeString, Nullable: true, Size: 64},
|
||||
{Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"},
|
||||
{Name: "subscription_type", Type: field.TypeString, Size: 20, Default: "standard"},
|
||||
{Name: "daily_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
@@ -896,12 +897,12 @@ var (
|
||||
{
|
||||
Name: "group_platform",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
},
|
||||
{
|
||||
Name: "group_subscription_type",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
Columns: []*schema.Column{GroupsColumns[15]},
|
||||
},
|
||||
{
|
||||
Name: "group_is_exclusive",
|
||||
@@ -916,7 +917,15 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[41]},
|
||||
Columns: []*schema.Column{GroupsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "idx_groups_duplicate_operation_id_active",
|
||||
Unique: true,
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
Annotation: &entsql.IndexAnnotation{
|
||||
Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+74
-1
@@ -20808,6 +20808,7 @@ type GroupMutation struct {
|
||||
addpeak_rate_multiplier *float64
|
||||
is_exclusive *bool
|
||||
status *string
|
||||
duplicate_operation_id *string
|
||||
platform *string
|
||||
subscription_type *string
|
||||
daily_limit_usd *float64
|
||||
@@ -21484,6 +21485,55 @@ func (m *GroupMutation) ResetStatus() {
|
||||
m.status = nil
|
||||
}
|
||||
|
||||
// SetDuplicateOperationID sets the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) SetDuplicateOperationID(s string) {
|
||||
m.duplicate_operation_id = &s
|
||||
}
|
||||
|
||||
// DuplicateOperationID returns the value of the "duplicate_operation_id" field in the mutation.
|
||||
func (m *GroupMutation) DuplicateOperationID() (r string, exists bool) {
|
||||
v := m.duplicate_operation_id
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldDuplicateOperationID returns the old "duplicate_operation_id" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldDuplicateOperationID(ctx context.Context) (v *string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldDuplicateOperationID is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldDuplicateOperationID requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldDuplicateOperationID: %w", err)
|
||||
}
|
||||
return oldValue.DuplicateOperationID, nil
|
||||
}
|
||||
|
||||
// ClearDuplicateOperationID clears the value of the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) ClearDuplicateOperationID() {
|
||||
m.duplicate_operation_id = nil
|
||||
m.clearedFields[group.FieldDuplicateOperationID] = struct{}{}
|
||||
}
|
||||
|
||||
// DuplicateOperationIDCleared returns if the "duplicate_operation_id" field was cleared in this mutation.
|
||||
func (m *GroupMutation) DuplicateOperationIDCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldDuplicateOperationID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetDuplicateOperationID resets all changes to the "duplicate_operation_id" field.
|
||||
func (m *GroupMutation) ResetDuplicateOperationID() {
|
||||
m.duplicate_operation_id = nil
|
||||
delete(m.clearedFields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
|
||||
// SetPlatform sets the "platform" field.
|
||||
func (m *GroupMutation) SetPlatform(s string) {
|
||||
m.platform = &s
|
||||
@@ -23714,7 +23764,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 48)
|
||||
fields := make([]string, 0, 49)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -23751,6 +23801,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.status != nil {
|
||||
fields = append(fields, group.FieldStatus)
|
||||
}
|
||||
if m.duplicate_operation_id != nil {
|
||||
fields = append(fields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
if m.platform != nil {
|
||||
fields = append(fields, group.FieldPlatform)
|
||||
}
|
||||
@@ -23891,6 +23944,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.IsExclusive()
|
||||
case group.FieldStatus:
|
||||
return m.Status()
|
||||
case group.FieldDuplicateOperationID:
|
||||
return m.DuplicateOperationID()
|
||||
case group.FieldPlatform:
|
||||
return m.Platform()
|
||||
case group.FieldSubscriptionType:
|
||||
@@ -23996,6 +24051,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldIsExclusive(ctx)
|
||||
case group.FieldStatus:
|
||||
return m.OldStatus(ctx)
|
||||
case group.FieldDuplicateOperationID:
|
||||
return m.OldDuplicateOperationID(ctx)
|
||||
case group.FieldPlatform:
|
||||
return m.OldPlatform(ctx)
|
||||
case group.FieldSubscriptionType:
|
||||
@@ -24161,6 +24218,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetStatus(v)
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetDuplicateOperationID(v)
|
||||
return nil
|
||||
case group.FieldPlatform:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
@@ -24704,6 +24768,9 @@ func (m *GroupMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(group.FieldDescription) {
|
||||
fields = append(fields, group.FieldDescription)
|
||||
}
|
||||
if m.FieldCleared(group.FieldDuplicateOperationID) {
|
||||
fields = append(fields, group.FieldDuplicateOperationID)
|
||||
}
|
||||
if m.FieldCleared(group.FieldDailyLimitUsd) {
|
||||
fields = append(fields, group.FieldDailyLimitUsd)
|
||||
}
|
||||
@@ -24763,6 +24830,9 @@ func (m *GroupMutation) ClearField(name string) error {
|
||||
case group.FieldDescription:
|
||||
m.ClearDescription()
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
m.ClearDuplicateOperationID()
|
||||
return nil
|
||||
case group.FieldDailyLimitUsd:
|
||||
m.ClearDailyLimitUsd()
|
||||
return nil
|
||||
@@ -24846,6 +24916,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldStatus:
|
||||
m.ResetStatus()
|
||||
return nil
|
||||
case group.FieldDuplicateOperationID:
|
||||
m.ResetDuplicateOperationID()
|
||||
return nil
|
||||
case group.FieldPlatform:
|
||||
m.ResetPlatform()
|
||||
return nil
|
||||
|
||||
@@ -995,102 +995,106 @@ func init() {
|
||||
group.DefaultStatus = groupDescStatus.Default.(string)
|
||||
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
|
||||
// groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field.
|
||||
groupDescDuplicateOperationID := groupFields[9].Descriptor()
|
||||
// group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
|
||||
group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error)
|
||||
// groupDescPlatform is the schema descriptor for platform field.
|
||||
groupDescPlatform := groupFields[9].Descriptor()
|
||||
groupDescPlatform := groupFields[10].Descriptor()
|
||||
// group.DefaultPlatform holds the default value on creation for the platform field.
|
||||
group.DefaultPlatform = groupDescPlatform.Default.(string)
|
||||
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
|
||||
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
|
||||
groupDescSubscriptionType := groupFields[10].Descriptor()
|
||||
groupDescSubscriptionType := groupFields[11].Descriptor()
|
||||
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
|
||||
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
|
||||
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
|
||||
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
|
||||
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
|
||||
groupDescDefaultValidityDays := groupFields[14].Descriptor()
|
||||
groupDescDefaultValidityDays := groupFields[15].Descriptor()
|
||||
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
|
||||
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
|
||||
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
|
||||
groupDescAllowImageGeneration := groupFields[15].Descriptor()
|
||||
groupDescAllowImageGeneration := groupFields[16].Descriptor()
|
||||
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
|
||||
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
|
||||
// groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field.
|
||||
groupDescAllowBatchImageGeneration := groupFields[16].Descriptor()
|
||||
groupDescAllowBatchImageGeneration := groupFields[17].Descriptor()
|
||||
// group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field.
|
||||
group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool)
|
||||
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
|
||||
groupDescImageRateIndependent := groupFields[17].Descriptor()
|
||||
groupDescImageRateIndependent := groupFields[18].Descriptor()
|
||||
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
|
||||
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
|
||||
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
|
||||
groupDescImageRateMultiplier := groupFields[18].Descriptor()
|
||||
groupDescImageRateMultiplier := groupFields[19].Descriptor()
|
||||
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
|
||||
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
|
||||
// groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field.
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[22].Descriptor()
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor()
|
||||
// group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field.
|
||||
group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64)
|
||||
// groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field.
|
||||
groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor()
|
||||
groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor()
|
||||
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
|
||||
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
|
||||
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
|
||||
groupDescVideoRateIndependent := groupFields[24].Descriptor()
|
||||
groupDescVideoRateIndependent := groupFields[25].Descriptor()
|
||||
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
|
||||
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
|
||||
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
|
||||
groupDescVideoRateMultiplier := groupFields[25].Descriptor()
|
||||
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
|
||||
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
|
||||
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[30].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
|
||||
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
|
||||
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
|
||||
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
|
||||
groupDescModelRoutingEnabled := groupFields[34].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[35].Descriptor()
|
||||
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
|
||||
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
|
||||
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
|
||||
groupDescMcpXMLInject := groupFields[35].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[36].Descriptor()
|
||||
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
|
||||
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
|
||||
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
|
||||
groupDescSupportedModelScopes := groupFields[36].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[37].Descriptor()
|
||||
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
|
||||
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
|
||||
// groupDescSortOrder is the schema descriptor for sort_order field.
|
||||
groupDescSortOrder := groupFields[37].Descriptor()
|
||||
groupDescSortOrder := groupFields[38].Descriptor()
|
||||
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
|
||||
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
|
||||
groupDescAllowMessagesDispatch := groupFields[38].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[39].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[39].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[40].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[40].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[41].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[41].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[42].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[43].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[43].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[44].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[44].Descriptor()
|
||||
groupDescRpmLimit := groupFields[45].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
|
||||
@@ -66,6 +66,12 @@ func (Group) Fields() []ent.Field {
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.StatusActive),
|
||||
field.String("duplicate_operation_id").
|
||||
MaxLen(64).
|
||||
Optional().
|
||||
Nillable().
|
||||
Immutable().
|
||||
Comment("内部幂等恢复标识,不对 API 暴露"),
|
||||
|
||||
// Subscription-related fields (added by migration 003)
|
||||
field.String("platform").
|
||||
@@ -244,5 +250,9 @@ func (Group) Indexes() []ent.Index {
|
||||
index.Fields("is_exclusive"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("sort_order"),
|
||||
index.Fields("duplicate_operation_id").
|
||||
Unique().
|
||||
StorageKey("idx_groups_duplicate_operation_id_active").
|
||||
Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
@@ -293,6 +297,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
|
||||
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -150,7 +149,7 @@ func (h *AuditLogHandler) Clear(c *gin.Context) {
|
||||
CredentialMasked: middleware.MaskedRequestCredential(c),
|
||||
Method: http.MethodPost,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: middleware.SecurityClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
@@ -373,8 +373,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
|
||||
}
|
||||
|
||||
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
|
||||
credentials := grokSSOImportCredentials(h.grokOAuthService.BuildAccountCredentials(tokenInfo), req.Credentials)
|
||||
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
|
||||
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
|
||||
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
|
||||
@@ -408,6 +407,18 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
}
|
||||
}
|
||||
|
||||
// grokSSOImportCredentials 合并 SSO 兑换出的凭据与导入请求携带的运营侧配置。
|
||||
// token 字段以 BuildAccountCredentials 为准(请求不可覆盖);但 base_url 是运营侧
|
||||
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
|
||||
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
|
||||
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
|
||||
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
|
||||
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
|
||||
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
|
||||
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
|
||||
return requestExpiresAt, requestAutoPause
|
||||
|
||||
@@ -223,6 +223,45 @@ func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.
|
||||
require.Same(t, &requestedAutoPause, autoPause)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) {
|
||||
built := map[string]any{
|
||||
"access_token": "at-1",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}
|
||||
reqCredentials := map[string]any{
|
||||
"base_url": "https://relay.example.com/v1",
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{"x-relay-key": "k"},
|
||||
}
|
||||
|
||||
credentials := grokSSOImportCredentials(built, reqCredentials)
|
||||
|
||||
// token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址
|
||||
require.Equal(t, "at-1", credentials["access_token"])
|
||||
require.Equal(t, "https://relay.example.com/v1", credentials["base_url"])
|
||||
require.Equal(t, true, credentials["header_override_enabled"])
|
||||
require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"])
|
||||
// 入参不被污染(req.Credentials 会被多个 worker 并发读取)
|
||||
require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"])
|
||||
}
|
||||
|
||||
func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
|
||||
built := map[string]any{
|
||||
"access_token": "at-1",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}
|
||||
|
||||
credentials := grokSSOImportCredentials(built, nil)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
|
||||
|
||||
credentials = grokSSOImportCredentials(map[string]any{
|
||||
"access_token": "at-2",
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
}, map[string]any{"base_url": " "})
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
|
||||
require.Equal(t, "at-2", credentials["access_token"])
|
||||
}
|
||||
|
||||
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
|
||||
h := &GrokOAuthHandler{}
|
||||
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
|
||||
|
||||
@@ -2,12 +2,15 @@ package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -361,6 +364,53 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Duplicate handles creating an inactive group copy with the source account bindings.
|
||||
// POST /api/v1/admin/groups/:id/duplicate
|
||||
func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.groups.duplicate",
|
||||
struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
}{GroupID: groupID},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
return dto.GroupFromServiceAdmin(group), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if recoverErr != nil {
|
||||
slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, dto.GroupFromServiceAdmin(recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Update handles updating a group
|
||||
// PUT /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Update(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateGroupAdminServiceStub struct {
|
||||
service.AdminService
|
||||
group *service.Group
|
||||
calls int
|
||||
recoverCalls int
|
||||
groupID int64
|
||||
actorScope string
|
||||
operationKey string
|
||||
recoverScope string
|
||||
recoverKey string
|
||||
created bool
|
||||
}
|
||||
|
||||
func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.calls++
|
||||
s.groupID = groupID
|
||||
s.actorScope = actorScope
|
||||
s.operationKey = operationKey
|
||||
s.created = true
|
||||
return s.group, nil
|
||||
}
|
||||
|
||||
func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.recoverCalls++
|
||||
s.recoverScope = actorScope
|
||||
s.recoverKey = operationKey
|
||||
if !s.created {
|
||||
return nil, nil
|
||||
}
|
||||
return s.group, nil
|
||||
}
|
||||
|
||||
func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(nil)
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
handler := NewGroupHandler(svc, nil, nil)
|
||||
router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate)
|
||||
return router
|
||||
}
|
||||
|
||||
func duplicateGroupHandlerFixture() *service.Group {
|
||||
return &service.Group{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: "inactive",
|
||||
RateMultiplier: 1,
|
||||
AccountCount: 3,
|
||||
ActiveAccountCount: 2,
|
||||
DuplicateOperationID: "internal-operation-must-not-leak",
|
||||
ModelRouting: map[string][]int64{"claude-*": {7}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.Contains(t, recorder.Body.String(), `"status":"inactive"`)
|
||||
require.Contains(t, recorder.Body.String(), `"account_count":3`)
|
||||
require.NotContains(t, recorder.Body.String(), "duplicate_operation_id")
|
||||
require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak")
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) {
|
||||
for _, id := range []string{"not-a-number", "0", "-1"} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, svc.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-group-42")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, int64(42), svc.groupID)
|
||||
require.Equal(t, "admin:77", svc.actorScope)
|
||||
require.Equal(t, "duplicate-group-42", svc.operationKey)
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
|
||||
}
|
||||
|
||||
func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
|
||||
router := setupDuplicateGroupRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Equal(t, 2, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey)
|
||||
}
|
||||
@@ -606,6 +606,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
|
||||
response.Success(c, gin.H{"affected": affected})
|
||||
}
|
||||
|
||||
// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users.
|
||||
// POST /api/v1/admin/users/batch-limits
|
||||
type BatchUpdateLimitsRequest struct {
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
All bool `json:"all"`
|
||||
Concurrency *int `json:"concurrency" binding:"omitempty,min=0"`
|
||||
RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) BatchUpdateLimits(c *gin.Context) {
|
||||
var req BatchUpdateLimitsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Concurrency == nil && req.RPMLimit == nil {
|
||||
response.BadRequest(c, "at least one of concurrency or rpm_limit is required")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.UserIDs) == 0 {
|
||||
response.BadRequest(c, "user_ids is required unless all=true")
|
||||
return
|
||||
}
|
||||
if !req.All && len(req.UserIDs) > 500 {
|
||||
response.BadRequest(c, "user_ids cannot exceed 500")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := req.UserIDs
|
||||
if req.All {
|
||||
userIDs = nil
|
||||
page := 1
|
||||
const pageSize = 500
|
||||
for {
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
if len(users) < pageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 {
|
||||
response.Success(c, gin.H{"affected": 0})
|
||||
return
|
||||
}
|
||||
|
||||
affected, err := h.adminService.BatchUpdateLimits(
|
||||
c.Request.Context(),
|
||||
userIDs,
|
||||
req.Concurrency,
|
||||
req.RPMLimit,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"affected": affected})
|
||||
}
|
||||
|
||||
// GetUserPlatformQuotas GET /admin/users/:id/platform-quotas
|
||||
// admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段
|
||||
func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type batchLimitsAdminServiceStub struct {
|
||||
*stubAdminService
|
||||
calls []batchLimitsAdminServiceCall
|
||||
}
|
||||
|
||||
type batchLimitsAdminServiceCall struct {
|
||||
userIDs []int64
|
||||
concurrency *int
|
||||
rpmLimit *int
|
||||
}
|
||||
|
||||
func cloneIntPointer(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
s.calls = append(s.calls, batchLimitsAdminServiceCall{
|
||||
userIDs: append([]int64(nil), userIDs...),
|
||||
concurrency: cloneIntPointer(concurrency),
|
||||
rpmLimit: cloneIntPointer(rpmLimit),
|
||||
})
|
||||
return len(userIDs), nil
|
||||
}
|
||||
|
||||
func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewUserHandler(serviceStub, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits)
|
||||
return router
|
||||
}
|
||||
|
||||
func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/users/batch-limits",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
expectedConcurrency *int
|
||||
expectedRPMLimit *int
|
||||
}{
|
||||
{name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)},
|
||||
{name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)},
|
||||
{name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
|
||||
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Len(t, serviceStub.calls, 1)
|
||||
require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs)
|
||||
require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency)
|
||||
require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit)
|
||||
|
||||
var response struct {
|
||||
Data struct {
|
||||
Affected int `json:"affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.Equal(t, 2, response.Data.Affected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) {
|
||||
tooManyIDs := make([]int64, 501)
|
||||
for index := range tooManyIDs {
|
||||
tooManyIDs[index] = int64(index + 1)
|
||||
}
|
||||
tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{name: "no limits", body: []byte(`{"user_ids":[1]}`)},
|
||||
{name: "invalid json", body: []byte(`{"user_ids":`)},
|
||||
{name: "missing user ids", body: []byte(`{"rpm_limit":10}`)},
|
||||
{name: "more than 500 ids", body: tooManyBody},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()}
|
||||
recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Empty(t, serviceStub.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) {
|
||||
base := newStubAdminService()
|
||||
base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}}
|
||||
serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base}
|
||||
recorder := postBatchLimits(
|
||||
t,
|
||||
setupBatchLimitsRouter(serviceStub),
|
||||
[]byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`),
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Len(t, serviceStub.calls, 1)
|
||||
require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs)
|
||||
require.Equal(t, 1, base.lastListUsers.calls)
|
||||
}
|
||||
|
||||
func pointerTo(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64,
|
||||
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
panic("unexpected BatchAddConcurrency call")
|
||||
}
|
||||
func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
panic("unexpected BatchUpdateLimits call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
||||
return map[int64]*time.Time{}, nil
|
||||
|
||||
@@ -1020,7 +1020,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(availableModels) > 0 {
|
||||
writeModelsList(c, availableModels)
|
||||
writeModelsList(c, platform, availableModels)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,10 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if platform == service.PlatformGrok {
|
||||
writeGrokModelsList(c, xai.DefaultModelIDs())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
@@ -1047,7 +1051,11 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func writeModelsList(c *gin.Context, modelIDs []string) {
|
||||
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
if platform == service.PlatformGrok {
|
||||
writeGrokModelsList(c, modelIDs)
|
||||
return
|
||||
}
|
||||
models := make([]claude.Model, 0, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
models = append(models, claude.Model{
|
||||
@@ -1068,7 +1076,66 @@ func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
writeOpenAIModelsList(c, modelIDs)
|
||||
return
|
||||
}
|
||||
writeModelsList(c, modelIDs)
|
||||
writeModelsList(c, platform, modelIDs)
|
||||
}
|
||||
|
||||
type grokReasoningEffortOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
type grokModelListItem struct {
|
||||
xai.Model
|
||||
SupportsReasoningEffort bool `json:"supportsReasoningEffort,omitempty"`
|
||||
ReasoningEffort string `json:"reasoningEffort,omitempty"`
|
||||
ReasoningEfforts []grokReasoningEffortOption `json:"reasoningEfforts,omitempty"`
|
||||
}
|
||||
|
||||
func writeGrokModelsList(c *gin.Context, modelIDs []string) {
|
||||
defaults := xai.DefaultModels()
|
||||
defaultsByID := make(map[string]xai.Model, len(defaults))
|
||||
for _, model := range defaults {
|
||||
defaultsByID[model.ID] = model
|
||||
}
|
||||
|
||||
models := make([]grokModelListItem, 0, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
model, ok := defaultsByID[modelID]
|
||||
if !ok {
|
||||
model = xai.Model{
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
OwnedBy: "xai",
|
||||
DisplayName: modelID,
|
||||
}
|
||||
}
|
||||
item := grokModelListItem{Model: model}
|
||||
if grokModelSupportsConfigurableReasoning(modelID) {
|
||||
item.SupportsReasoningEffort = true
|
||||
item.ReasoningEffort = "high"
|
||||
item.ReasoningEfforts = []grokReasoningEffortOption{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
}
|
||||
}
|
||||
models = append(models, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})
|
||||
}
|
||||
|
||||
func grokModelSupportsConfigurableReasoning(modelID string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(modelID)) {
|
||||
case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||
|
||||
@@ -25,11 +25,20 @@ type gatewayModelsResponseForTest struct {
|
||||
}
|
||||
|
||||
type gatewayModelItemForTest struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
SupportsReasoningEffort bool `json:"supportsReasoningEffort"`
|
||||
ReasoningEffort string `json:"reasoningEffort"`
|
||||
ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"`
|
||||
}
|
||||
|
||||
type gatewayReasoningEffortOptionForTest struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
|
||||
@@ -84,6 +93,50 @@ func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6")
|
||||
}
|
||||
|
||||
func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(4409)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.5": "grok-4.5"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Data, 1)
|
||||
model := got.Data[0]
|
||||
require.Equal(t, "grok-4.5", model.ID)
|
||||
require.True(t, model.SupportsReasoningEffort)
|
||||
require.Equal(t, "high", model.ReasoningEffort)
|
||||
require.Equal(t, []gatewayReasoningEffortOptionForTest{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
}, model.ReasoningEfforts)
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ type codexModelsFailoverHTTPUpstream struct {
|
||||
accountIDs []int64
|
||||
firstErr error
|
||||
firstStatus int
|
||||
firstBody string
|
||||
statuses map[int64]int
|
||||
}
|
||||
|
||||
@@ -62,6 +63,14 @@ func (u *codexModelsFailoverHTTPUpstream) Do(_ *http.Request, _ string, accountI
|
||||
if u.firstErr != nil {
|
||||
return nil, u.firstErr
|
||||
}
|
||||
if u.firstBody != "" && !hasStatus {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(u.firstBody)),
|
||||
}, nil
|
||||
}
|
||||
if !hasStatus {
|
||||
status = u.firstStatus
|
||||
}
|
||||
@@ -150,6 +159,22 @@ func TestCodexModelsFailsOverFromUpstreamTransportError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusOK)
|
||||
upstream.firstBody = `{"object":"list","data":[]}`
|
||||
recorder := performCodexModelsRequest(t, handler, groupID)
|
||||
|
||||
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
|
||||
t.Fatalf("upstream account calls: got %v, want %v", got, want)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
statuses := []int{
|
||||
http.StatusBadRequest,
|
||||
|
||||
@@ -93,6 +93,9 @@ func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int)
|
||||
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
||||
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
|
||||
@@ -54,6 +54,17 @@ func GetTrustedClientIP(c *gin.Context) string {
|
||||
return normalizeIP(c.ClientIP())
|
||||
}
|
||||
|
||||
// GetSecurityClientIP 返回安全敏感场景(API Key IP 限制、审计日志、会话 IP/UA 绑定)
|
||||
// 使用的客户端 IP。trustForwarded 对应系统设置「信任反代传递的客户端 IP」:
|
||||
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
|
||||
// 关闭时走 Gin trusted_proxies 解析链。
|
||||
func GetSecurityClientIP(c *gin.Context, trustForwarded bool) string {
|
||||
if trustForwarded {
|
||||
return GetClientIP(c)
|
||||
}
|
||||
return GetTrustedClientIP(c)
|
||||
}
|
||||
|
||||
// normalizeIP 规范化 IP 地址,去除端口号和空格。
|
||||
func normalizeIP(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
|
||||
@@ -94,3 +94,33 @@ func TestCheckIPRestrictionWithCompiledRules_InvalidWhitelistStillDenies(t *test
|
||||
require.False(t, allowed)
|
||||
require.Equal(t, "access denied", reason)
|
||||
}
|
||||
|
||||
func TestGetSecurityClientIPHonorsTrustToggle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
trustForwarded bool
|
||||
want string
|
||||
}{
|
||||
{name: "trust disabled uses trusted proxy chain", trustForwarded: false, want: "9.9.9.9"},
|
||||
{name: "trust enabled uses forwarded header", trustForwarded: true, want: "1.2.3.4"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
c.String(200, GetSecurityClientIP(c, tc.trustForwarded))
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "9.9.9.9:12345"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
require.Equal(t, tc.want, w.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ const (
|
||||
|
||||
var (
|
||||
oauthEndpointAllowedHosts = []string{"x.ai", "*.x.ai"}
|
||||
baseURLAllowedHosts = []string{"api.x.ai", "cli-chat-proxy.grok.com"}
|
||||
// *.api.x.ai 覆盖 xAI 区域端点(us-east-1/us-west-2/eu-west-1 等),
|
||||
// 运营方可在端点间手动切换以规避单点不可用。
|
||||
baseURLAllowedHosts = []string{"api.x.ai", "*.api.x.ai", "cli-chat-proxy.grok.com"}
|
||||
)
|
||||
|
||||
// OAuthSession stores one PKCE OAuth flow.
|
||||
@@ -332,10 +334,17 @@ func normalizeKnownBaseURLPath(raw string) (string, error) {
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
// IsOfficialBaseURLHost 报告 host 是否属于官方 API / CLI 网关主机。
|
||||
// IsOfficialBaseURLHost 报告 host 是否属于官方 API / 区域 API / CLI 网关主机。
|
||||
func IsOfficialBaseURLHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
for _, allowed := range baseURLAllowedHosts {
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := strings.TrimPrefix(allowed, "*.")
|
||||
if host == suffix || strings.HasSuffix(host, "."+suffix) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if host == allowed {
|
||||
return true
|
||||
}
|
||||
@@ -343,7 +352,18 @@ func IsOfficialBaseURLHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai 或 CLI 网关),
|
||||
// IsParseableBaseURL 报告 raw 是否能解析出 host。
|
||||
// 供读取路径判定存量脏数据:无法解析的值应回落默认端点,而不是把流量发往未定义目标。
|
||||
func IsParseableBaseURL(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
return err == nil && parsed.Host != ""
|
||||
}
|
||||
|
||||
// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai / *.api.x.ai 区域端点 / CLI 网关),
|
||||
// 容忍存量凭证中的历史变体(大小写、显式 443 端口、百分号编码 path 等)。
|
||||
// 无法解析的值一并视为官方,调用方据此回落默认端点而不是把流量发往未定义目标。
|
||||
func IsOfficialBaseURL(raw string) bool {
|
||||
|
||||
@@ -211,12 +211,32 @@ func TestIsOfficialBaseURL(t *testing.T) {
|
||||
"https://relay.example.test/xai/v1",
|
||||
"http://relay.example.test/v1",
|
||||
"https://grok.com.evil.example.test/v1",
|
||||
"https://api.x.ai.evil.example.test/v1", // 后缀伪装不属于 *.api.x.ai
|
||||
}
|
||||
for _, raw := range custom {
|
||||
require.False(t, IsOfficialBaseURL(raw), "expected custom: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionalAPIEndpointsAreOfficialAndTrusted(t *testing.T) {
|
||||
regional := []string{
|
||||
"https://us-east-1.api.x.ai/v1",
|
||||
"https://us-west-2.api.x.ai/v1",
|
||||
"https://eu-west-1.api.x.ai/v1",
|
||||
}
|
||||
for _, raw := range regional {
|
||||
require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw)
|
||||
|
||||
validated, err := ValidateTrustedBaseURL(raw)
|
||||
require.NoError(t, err, "trusted validation should accept regional endpoint %q", raw)
|
||||
require.Equal(t, raw, validated)
|
||||
}
|
||||
|
||||
// 区域端点作为官方主机同样强制 /v1 path
|
||||
_, err := ValidateTrustedBaseURL("https://us-east-1.api.x.ai/other")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateBaseURLsRejectEmptyQueryDelimiter(t *testing.T) {
|
||||
_, err := ValidateBaseURL("https://grok.example.test/v1?")
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -928,6 +928,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
IsExclusive: g.IsExclusive,
|
||||
Status: g.Status,
|
||||
Hydrated: true,
|
||||
DuplicateOperationID: derefString(g.DuplicateOperationID),
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
DailyLimitUSD: g.DailyLimitUsd,
|
||||
WeeklyLimitUSD: g.WeeklyLimitUsd,
|
||||
|
||||
@@ -32,12 +32,31 @@ func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupReposi
|
||||
return newGroupRepositoryWithSQL(client, sqlDB)
|
||||
}
|
||||
|
||||
// NewAdminGroupRepository exposes the atomic group-duplication capability as
|
||||
// an explicit dependency of the admin service.
|
||||
func NewAdminGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.AdminGroupRepository {
|
||||
return newGroupRepositoryWithSQL(client, sqlDB)
|
||||
}
|
||||
|
||||
func newGroupRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *groupRepository {
|
||||
return &groupRepository{client: client, sql: sqlq}
|
||||
}
|
||||
|
||||
func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) error {
|
||||
builder := r.client.Group.Create().
|
||||
if err := createGroupRecord(ctx, r.client, groupIn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
|
||||
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *service.Group) error {
|
||||
if groupIn == nil {
|
||||
return errors.New("group is nil")
|
||||
}
|
||||
builder := client.Group.Create().
|
||||
SetName(groupIn.Name).
|
||||
SetDescription(groupIn.Description).
|
||||
SetPlatform(groupIn.Platform).
|
||||
@@ -81,6 +100,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
SetPeakEnd(groupIn.PeakEnd).
|
||||
SetPeakRateMultiplier(groupIn.PeakRateMultiplier)
|
||||
if groupIn.DuplicateOperationID != "" {
|
||||
builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID)
|
||||
}
|
||||
|
||||
// 设置模型路由配置
|
||||
if groupIn.ModelRouting != nil {
|
||||
@@ -91,15 +113,87 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
|
||||
builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes)
|
||||
|
||||
created, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, nil, service.ErrGroupExists)
|
||||
}
|
||||
groupIn.ID = created.ID
|
||||
groupIn.CreatedAt = created.CreatedAt
|
||||
groupIn.UpdatedAt = created.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *groupRepository) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) {
|
||||
operationID = strings.TrimSpace(operationID)
|
||||
if operationID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row, err := r.client.Group.Query().
|
||||
Where(group.DuplicateOperationIDEQ(operationID)).
|
||||
Order(dbent.Asc(group.FieldID)).
|
||||
First(ctx)
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find group duplicate operation: %w", err)
|
||||
}
|
||||
return groupEntityToService(row), nil
|
||||
}
|
||||
|
||||
// CreateFromSource atomically persists a copied group, clones the source
|
||||
// account bindings with their exact priorities, and writes its scheduler event.
|
||||
func (r *groupRepository) CreateFromSource(ctx context.Context, groupIn *service.Group, sourceGroupID int64) error {
|
||||
if groupIn == nil {
|
||||
return errors.New("group is nil")
|
||||
}
|
||||
tx, err := r.client.Tx(ctx)
|
||||
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
|
||||
return err
|
||||
}
|
||||
|
||||
var txClient *dbent.Client
|
||||
if err == nil {
|
||||
groupIn.ID = created.ID
|
||||
groupIn.CreatedAt = created.CreatedAt
|
||||
groupIn.UpdatedAt = created.UpdatedAt
|
||||
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
|
||||
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
txClient = tx.Client()
|
||||
} else {
|
||||
// Reuse a caller-owned transaction when this repository is already transactional.
|
||||
txClient = r.client
|
||||
}
|
||||
|
||||
if err := createGroupRecord(ctx, txClient, groupIn); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := txClient.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO account_groups (account_id, group_id, priority, created_at)
|
||||
SELECT ag.account_id, $2, ag.priority, NOW()
|
||||
FROM account_groups ag
|
||||
JOIN accounts a ON a.id = ag.account_id
|
||||
WHERE ag.group_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
AND (NOT $3 OR a.type <> $4)
|
||||
ON CONFLICT (account_id, group_id) DO NOTHING`,
|
||||
sourceGroupID,
|
||||
groupIn.ID,
|
||||
groupIn.RequireOAuthOnly,
|
||||
service.AccountTypeAPIKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, countErr := result.RowsAffected(); countErr == nil {
|
||||
groupIn.AccountCount = count
|
||||
}
|
||||
if err := enqueueSchedulerOutbox(ctx, txClient, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tx != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return translatePersistenceError(err, nil, service.ErrGroupExists)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group, error) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateGroupFromSourceRollsBackWhenOutboxInsertFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := testEntClient(t)
|
||||
repo := newGroupRepositoryWithSQL(client, integrationDB)
|
||||
suffix := time.Now().UnixNano()
|
||||
operationID := strings.Repeat("b", 64)
|
||||
|
||||
source, err := client.Group.Create().
|
||||
SetName(fmt.Sprintf("duplicate-rollback-source-%d", suffix)).
|
||||
SetPlatform(service.PlatformAnthropic).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
account, err := client.Account.Create().
|
||||
SetName(fmt.Sprintf("duplicate-rollback-account-%d", suffix)).
|
||||
SetPlatform(service.PlatformAnthropic).
|
||||
SetType(service.AccountTypeOAuth).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
_, err = client.AccountGroup.Create().
|
||||
SetAccountID(account.ID).
|
||||
SetGroupID(source.ID).
|
||||
SetPriority(29).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
functionName := fmt.Sprintf("fail_group_duplicate_outbox_%d", suffix)
|
||||
triggerName := fmt.Sprintf("fail_group_duplicate_outbox_trigger_%d", suffix)
|
||||
_, err = integrationDB.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.group_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM groups
|
||||
WHERE id = NEW.group_id AND duplicate_operation_id = '%s'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'forced duplicate outbox failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$`, functionName, operationID))
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, fmt.Sprintf(
|
||||
"CREATE TRIGGER %s BEFORE INSERT ON scheduler_outbox FOR EACH ROW EXECUTE FUNCTION %s()",
|
||||
triggerName,
|
||||
functionName,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
|
||||
duplicateName := fmt.Sprintf("duplicate-rollback-copy-%d", suffix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON scheduler_outbox", triggerName))
|
||||
_, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP FUNCTION IF EXISTS %s()", functionName))
|
||||
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM scheduler_outbox WHERE group_id = $1", source.ID)
|
||||
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE account_id = $1", account.ID)
|
||||
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", account.ID)
|
||||
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE name IN ($1, $2)", source.Name, duplicateName)
|
||||
})
|
||||
|
||||
duplicate := &service.Group{
|
||||
Name: duplicateName,
|
||||
Platform: source.Platform,
|
||||
RateMultiplier: 1,
|
||||
Status: "inactive",
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
DuplicateOperationID: operationID,
|
||||
}
|
||||
err = repo.CreateFromSource(ctx, duplicate, source.ID)
|
||||
require.ErrorContains(t, err, "forced duplicate outbox failure")
|
||||
|
||||
var groupCount, bindingCount, outboxCount int
|
||||
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM groups WHERE name = $1", duplicateName).Scan(&groupCount))
|
||||
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", duplicate.ID).Scan(&bindingCount))
|
||||
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1", duplicate.ID).Scan(&outboxCount))
|
||||
require.Zero(t, groupCount)
|
||||
require.Zero(t, bindingCount)
|
||||
require.Zero(t, outboxCount)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
@@ -67,6 +68,91 @@ func (s *GroupRepoSuite) TestCreate() {
|
||||
s.Require().Equal("test-create", got.Name)
|
||||
}
|
||||
|
||||
func (s *GroupRepoSuite) TestCreateFromSourcePreservesPriorityAndFiltersIneligibleAccounts() {
|
||||
source := &service.Group{
|
||||
Name: "duplicate-source",
|
||||
Platform: service.PlatformOpenAI,
|
||||
RateMultiplier: 1,
|
||||
Status: service.StatusActive,
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
RequireOAuthOnly: true,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, source))
|
||||
|
||||
insertAccount := func(name, accountType string, deleted bool) int64 {
|
||||
var id int64
|
||||
deletedAt := any(nil)
|
||||
if deleted {
|
||||
deletedAt = "2026-07-16T00:00:00Z"
|
||||
}
|
||||
s.Require().NoError(scanSingleRow(
|
||||
s.ctx,
|
||||
s.tx,
|
||||
"INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
[]any{name, service.PlatformOpenAI, accountType, deletedAt},
|
||||
&id,
|
||||
))
|
||||
return id
|
||||
}
|
||||
oauthID := insertAccount("duplicate-oauth", service.AccountTypeOAuth, false)
|
||||
apiKeyID := insertAccount("duplicate-apikey", service.AccountTypeAPIKey, false)
|
||||
deletedID := insertAccount("duplicate-deleted", service.AccountTypeOAuth, true)
|
||||
for _, binding := range []struct {
|
||||
accountID int64
|
||||
priority int
|
||||
}{{oauthID, 37}, {apiKeyID, 8}, {deletedID, 3}} {
|
||||
_, err := s.tx.ExecContext(
|
||||
s.ctx,
|
||||
"INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())",
|
||||
binding.accountID,
|
||||
source.ID,
|
||||
binding.priority,
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
}
|
||||
|
||||
duplicate := &service.Group{
|
||||
Name: "duplicate-source (Copy)",
|
||||
Platform: source.Platform,
|
||||
RateMultiplier: source.RateMultiplier,
|
||||
Status: "inactive",
|
||||
SubscriptionType: source.SubscriptionType,
|
||||
RequireOAuthOnly: true,
|
||||
DuplicateOperationID: strings.Repeat("a", 64),
|
||||
}
|
||||
s.Require().NoError(s.repo.CreateFromSource(s.ctx, duplicate, source.ID))
|
||||
s.Require().EqualValues(1, duplicate.AccountCount)
|
||||
|
||||
rows, err := s.tx.QueryContext(
|
||||
s.ctx,
|
||||
"SELECT account_id, priority FROM account_groups WHERE group_id = $1 ORDER BY account_id",
|
||||
duplicate.ID,
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
defer func() { _ = rows.Close() }()
|
||||
s.Require().True(rows.Next())
|
||||
var copiedAccountID int64
|
||||
var copiedPriority int
|
||||
s.Require().NoError(rows.Scan(&copiedAccountID, &copiedPriority))
|
||||
s.Require().Equal(oauthID, copiedAccountID)
|
||||
s.Require().Equal(37, copiedPriority)
|
||||
s.Require().False(rows.Next(), "API-key and soft-deleted accounts must not be copied")
|
||||
|
||||
recovered, err := s.repo.FindByDuplicateOperationID(s.ctx, duplicate.DuplicateOperationID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(duplicate.ID, recovered.ID)
|
||||
|
||||
var outboxCount int
|
||||
s.Require().NoError(scanSingleRow(
|
||||
s.ctx,
|
||||
s.tx,
|
||||
"SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1",
|
||||
[]any{duplicate.ID},
|
||||
&outboxCount,
|
||||
))
|
||||
s.Require().Equal(1, outboxCount)
|
||||
}
|
||||
|
||||
func (s *GroupRepoSuite) TestGetByID_NotFound() {
|
||||
_, err := s.repo.GetByID(s.ctx, 999999)
|
||||
s.Require().Error(err, "expected error for non-existent ID")
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
@@ -73,8 +74,10 @@ const (
|
||||
// client version. Keep a known-good stable version in the binary while
|
||||
// allowing operators to bump it without waiting for a Sub2API release.
|
||||
grokCLIProxyHost = "cli-chat-proxy.grok.com"
|
||||
grokOfficialAPIHost = "api.x.ai"
|
||||
grokCLIStableVersion = "0.2.93"
|
||||
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
|
||||
grokFallbackBodyLimit = 64 << 10
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -195,7 +198,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req)
|
||||
client := httpClientForUpstreamRequest(entry.client, req)
|
||||
client = httpClientWithGrokAccessDeniedFallback(client)
|
||||
resp, err := servertiming.Do(client, req)
|
||||
if err != nil {
|
||||
s.recordOpenAIHTTP2Failure(profile, entry.protocolMode, entry.proxyKey, err)
|
||||
// 请求失败,立即减少计数
|
||||
@@ -257,7 +262,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req)
|
||||
client := httpClientForUpstreamRequest(entry.client, req)
|
||||
client = httpClientWithGrokAccessDeniedFallback(client)
|
||||
resp, err := servertiming.Do(client, req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
@@ -286,6 +293,129 @@ func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.
|
||||
return &clone
|
||||
}
|
||||
|
||||
// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as
|
||||
// the primary OAuth route, but retries a replayable request against api.x.ai
|
||||
// when the proxy returns its compatibility-specific 403 "Access denied".
|
||||
// Trial subscriptions can hit this boundary while the same OAuth credential
|
||||
// remains valid on the official API. Other entitlement failures stay on the
|
||||
// original response so account scheduling semantics do not change.
|
||||
type grokAccessDeniedFallbackTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func httpClientWithGrokAccessDeniedFallback(client *http.Client) *http.Client {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *client
|
||||
base := clone.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
clone.Transport = &grokAccessDeniedFallbackTransport{base: base}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (t *grokAccessDeniedFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || !isGrokCLIAccessDeniedFallbackCandidate(req, resp) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, ok := bufferSmallResponseBody(resp, grokFallbackBodyLimit)
|
||||
if !ok || !bytes.Contains(bytes.ToLower(body), []byte("access denied")) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
fallbackReq, err := newGrokOfficialAPIFallbackRequest(req)
|
||||
if err != nil {
|
||||
return resp, nil
|
||||
}
|
||||
fallbackResp, fallbackErr := t.base.RoundTrip(fallbackReq)
|
||||
if fallbackErr != nil {
|
||||
slog.Debug("grok_cli_access_denied_api_fallback_failed", "path", req.URL.EscapedPath(), "error", fallbackErr)
|
||||
return resp, nil
|
||||
}
|
||||
if fallbackResp.StatusCode < http.StatusOK || fallbackResp.StatusCode >= http.StatusMultipleChoices {
|
||||
if fallbackResp.Body != nil {
|
||||
_ = fallbackResp.Body.Close()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
slog.Warn("grok_cli_access_denied_api_fallback_succeeded", "method", req.Method, "path", req.URL.EscapedPath())
|
||||
return fallbackResp, nil
|
||||
}
|
||||
|
||||
func isGrokCLIAccessDeniedFallbackCandidate(req *http.Request, resp *http.Response) bool {
|
||||
return req != nil && req.URL != nil && req.GetBody != nil && resp != nil &&
|
||||
resp.StatusCode == http.StatusForbidden &&
|
||||
strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) &&
|
||||
strings.EqualFold(strings.TrimSpace(req.Header.Get("X-XAI-Token-Auth")), "xai-grok-cli") &&
|
||||
strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Header.Get("Authorization"))), "bearer ")
|
||||
}
|
||||
|
||||
func newGrokOfficialAPIFallbackRequest(req *http.Request) (*http.Request, error) {
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallbackReq := req.Clone(req.Context())
|
||||
fallbackReq.Body = body
|
||||
fallbackReq.URL = cloneURL(req.URL)
|
||||
fallbackReq.URL.Scheme = "https"
|
||||
fallbackReq.URL.Host = grokOfficialAPIHost
|
||||
fallbackReq.Host = ""
|
||||
fallbackReq.RequestURI = ""
|
||||
fallbackReq.Header = req.Header.Clone()
|
||||
for _, header := range []string{
|
||||
"X-XAI-Token-Auth",
|
||||
"X-Grok-Client-Version",
|
||||
"X-Grok-Client-Surface",
|
||||
"X-UserID",
|
||||
"X-Email",
|
||||
"User-Agent",
|
||||
} {
|
||||
fallbackReq.Header.Del(header)
|
||||
}
|
||||
return fallbackReq, nil
|
||||
}
|
||||
|
||||
func cloneURL(value *url.URL) *url.URL {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *value
|
||||
return &clone
|
||||
}
|
||||
|
||||
func bufferSmallResponseBody(resp *http.Response, limit int64) ([]byte, bool) {
|
||||
if resp == nil || resp.Body == nil || limit <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
original := resp.Body
|
||||
body, err := io.ReadAll(io.LimitReader(original, limit+1))
|
||||
if err != nil || int64(len(body)) > limit {
|
||||
resp.Body = &prefixedReadCloser{
|
||||
Reader: io.MultiReader(bytes.NewReader(body), original),
|
||||
Closer: original,
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
_ = original.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
return body, true
|
||||
}
|
||||
|
||||
type prefixedReadCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// applyGrokCLIProxyHeaders applies the official Grok Build client identity at
|
||||
// the final shared transport boundary. Keying this behavior to the exact CLI
|
||||
// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -240,6 +242,100 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T) {
|
||||
upstream := NewHTTPUpstream(nil)
|
||||
svc, ok := upstream.(*httpUpstreamService)
|
||||
require.True(t, ok)
|
||||
|
||||
const accountID int64 = 4421
|
||||
isolation := svc.getIsolationMode()
|
||||
profile := service.HTTPUpstreamProfileDefault
|
||||
proxyKey := directProxyKey
|
||||
protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
|
||||
settings := svc.resolvePoolSettings(isolation, 1)
|
||||
settings = svc.applyProfilePoolSettings(settings, profile)
|
||||
cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
|
||||
|
||||
payload := []byte(`{"model":"grok-4.5","input":"hello"}`)
|
||||
var calls int
|
||||
var fallbackBody []byte
|
||||
var fallbackHeaders http.Header
|
||||
svc.clients[cacheKey] = &upstreamClientEntry{
|
||||
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
if calls == 1 {
|
||||
require.Equal(t, grokCLIProxyHost, req.URL.Hostname())
|
||||
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"Access denied"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fallbackBody = body
|
||||
fallbackHeaders = req.Header.Clone()
|
||||
require.Equal(t, grokOfficialAPIHost, req.URL.Hostname())
|
||||
require.Equal(t, "/v1/responses", req.URL.Path)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"response-ok"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})},
|
||||
proxyKey: proxyKey,
|
||||
poolKey: buildPoolKey(settings, protocolMode),
|
||||
protocolMode: protocolMode,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", bytes.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer oauth-token")
|
||||
|
||||
resp, err := svc.Do(req, "", accountID, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.JSONEq(t, `{"id":"response-ok"}`, string(responseBody))
|
||||
require.Equal(t, 2, calls)
|
||||
require.Equal(t, payload, fallbackBody)
|
||||
require.Equal(t, "Bearer oauth-token", fallbackHeaders.Get("Authorization"))
|
||||
require.Empty(t, fallbackHeaders.Get("X-XAI-Token-Auth"))
|
||||
require.Empty(t, fallbackHeaders.Get("x-grok-client-version"))
|
||||
require.Empty(t, fallbackHeaders.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestHTTPUpstreamDoDoesNotFallbackForGrokEntitlementDenial(t *testing.T) {
|
||||
transport := &grokAccessDeniedFallbackTransport{
|
||||
base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"subscription required"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", strings.NewReader(`{"model":"grok-4.5"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer oauth-token")
|
||||
req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.JSONEq(t, `{"error":"subscription required"}`, string(body))
|
||||
}
|
||||
|
||||
func TestApplyGrokCLIProxyHeaders(t *testing.T) {
|
||||
t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "")
|
||||
|
||||
@@ -867,6 +867,39 @@ func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int6
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
func (r *userRepository) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
if len(userIDs) == 0 || (concurrency == nil && rpmLimit == nil) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
setClauses := make([]string, 0, 3)
|
||||
args := make([]any, 0, 3)
|
||||
if concurrency != nil {
|
||||
value := max(*concurrency, 0)
|
||||
args = append(args, value)
|
||||
setClauses = append(setClauses, fmt.Sprintf("concurrency = $%d", len(args)))
|
||||
}
|
||||
if rpmLimit != nil {
|
||||
value := max(*rpmLimit, 0)
|
||||
args = append(args, value)
|
||||
setClauses = append(setClauses, fmt.Sprintf("rpm_limit = $%d", len(args)))
|
||||
}
|
||||
setClauses = append(setClauses, "updated_at = NOW()")
|
||||
args = append(args, pq.Array(userIDs))
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"UPDATE users SET %s WHERE id = ANY($%d) AND deleted_at IS NULL",
|
||||
strings.Join(setClauses, ", "),
|
||||
len(args),
|
||||
)
|
||||
res, err := r.sql.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("batch update user limits: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,60 @@ func (s *UserRepoSuite) TestUpdate() {
|
||||
s.Require().Equal("updated", updated.Username)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesOnlyProvidedFields() {
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "batch-limits-one-field@test.com",
|
||||
Concurrency: 4,
|
||||
RPMLimit: 20,
|
||||
})
|
||||
concurrency := 9
|
||||
|
||||
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, nil)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(1, affected)
|
||||
|
||||
updated, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(9, updated.Concurrency)
|
||||
s.Equal(20, updated.RPMLimit)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesBothFieldsToZero() {
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "batch-limits-zero@test.com",
|
||||
Concurrency: 4,
|
||||
RPMLimit: 20,
|
||||
})
|
||||
zero := 0
|
||||
|
||||
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &zero, &zero)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(1, affected)
|
||||
|
||||
updated, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Zero(updated.Concurrency)
|
||||
s.Zero(updated.RPMLimit)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestBatchUpdateLimitsIgnoresDeletedUsersAndReturnsAffectedRows() {
|
||||
active := s.mustCreateUser(&service.User{Email: "batch-limits-active@test.com", RPMLimit: 10})
|
||||
deleted := s.mustCreateUser(&service.User{Email: "batch-limits-deleted@test.com", RPMLimit: 10})
|
||||
s.Require().NoError(s.client.User.DeleteOneID(deleted.ID).Exec(s.ctx))
|
||||
rpmLimit := 45
|
||||
|
||||
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{active.ID, deleted.ID}, nil, &rpmLimit)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(1, affected)
|
||||
|
||||
updatedActive, err := s.repo.GetByID(s.ctx, active.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(45, updatedActive.RPMLimit)
|
||||
updatedDeleted, err := s.repo.GetByIDIncludeDeleted(s.ctx, deleted.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(10, updatedDeleted.RPMLimit)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() {
|
||||
user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"})
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserRepository,
|
||||
NewAPIKeyRepository,
|
||||
NewGroupRepository,
|
||||
NewAdminGroupRepository,
|
||||
NewAccountRepository,
|
||||
NewAdminAccountRepository,
|
||||
NewScheduledTestPlanRepository, // 定时测试计划仓储
|
||||
|
||||
@@ -1557,6 +1557,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
|
||||
|
||||
func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (r *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
return false, errors.New("not implemented")
|
||||
@@ -1731,6 +1734,14 @@ func (stubGroupRepo) UpdateSortOrders(ctx context.Context, updates []service.Gro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stubGroupRepo) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubGroupRepo) CreateFromSource(ctx context.Context, group *service.Group, sourceGroupID int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
type stubAccountRepo struct {
|
||||
bulkUpdateIDs []int64
|
||||
}
|
||||
|
||||
@@ -200,6 +200,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
|
||||
|
||||
func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
panic("unexpected ExistsByEmail call")
|
||||
|
||||
@@ -97,10 +97,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
// 检查 IP 限制(白名单/黑名单)
|
||||
// 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制
|
||||
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
|
||||
clientIP := ip.GetTrustedClientIP(c)
|
||||
if cfg.TrustForwardedIPForAPIKeyACL() {
|
||||
clientIP = ip.GetClientIP(c)
|
||||
}
|
||||
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
|
||||
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
|
||||
if !allowed {
|
||||
if clientIP == "" {
|
||||
|
||||
@@ -59,10 +59,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
|
||||
// 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。
|
||||
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
|
||||
clientIP := ip.GetTrustedClientIP(c)
|
||||
if cfg.TrustForwardedIPForAPIKeyACL() {
|
||||
clientIP = ip.GetClientIP(c)
|
||||
}
|
||||
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
|
||||
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
|
||||
if !allowed {
|
||||
if clientIP == "" {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -147,7 +146,7 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle
|
||||
Action: action,
|
||||
Method: c.Request.Method,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: SecurityClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestBody: bodyRedacted,
|
||||
StatusCode: status,
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SessionBindingContext 全局中间件:将请求的可信客户端 IP 与 User-Agent 注入
|
||||
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定。
|
||||
// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。
|
||||
func SessionBindingContext() gin.HandlerFunc {
|
||||
// SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入
|
||||
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定,
|
||||
// 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。
|
||||
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关:
|
||||
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
|
||||
// 关闭时走 trusted_proxies 解析链,避免不可信头伪造绕过绑定。
|
||||
func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
binding := &service.SessionBinding{
|
||||
IP: ip.GetTrustedClientIP(c),
|
||||
IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
|
||||
@@ -21,13 +27,27 @@ func SessionBindingContext() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// currentSessionBindingHash 计算当前请求的会话指纹哈希。
|
||||
func currentSessionBindingHash(c *gin.Context) string {
|
||||
binding := &service.SessionBinding{
|
||||
// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
|
||||
// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies
|
||||
// 链回退兜底(等价于开关关闭时的行为)。
|
||||
func requestSessionBinding(c *gin.Context) *service.SessionBinding {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil {
|
||||
return binding
|
||||
}
|
||||
return &service.SessionBinding{
|
||||
IP: ip.GetTrustedClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
return binding.Hash()
|
||||
}
|
||||
|
||||
// SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。
|
||||
// 与会话绑定、API Key IP 限制共用同一套「信任反代传递的客户端 IP」开关语义。
|
||||
func SecurityClientIP(c *gin.Context) string {
|
||||
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil &&
|
||||
strings.TrimSpace(binding.IP) != "" {
|
||||
return binding.IP
|
||||
}
|
||||
return ip.GetTrustedClientIP(c)
|
||||
}
|
||||
|
||||
// enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。
|
||||
@@ -49,7 +69,8 @@ func enforceSessionBinding(
|
||||
if claims == nil || claims.BindingHash == "" {
|
||||
return true
|
||||
}
|
||||
current := currentSessionBindingHash(c)
|
||||
binding := requestSessionBinding(c)
|
||||
current := binding.Hash()
|
||||
if current == "" || current == claims.BindingHash {
|
||||
return true
|
||||
}
|
||||
@@ -71,7 +92,7 @@ func enforceSessionBinding(
|
||||
Action: service.AuditActionSessionBindingMismatch,
|
||||
Method: c.Request.Method,
|
||||
Path: path,
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
ClientIP: binding.IP,
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: 401,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build unit
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 反代场景:RemoteAddr 为 127.0.0.1,真实客户端 IP 在 X-Real-IP 中。
|
||||
// 会话绑定注入与审计 IP 必须与 API Key IP 限制共用「信任反代传递的客户端 IP」开关语义。
|
||||
func TestSessionBindingContextHonorsTrustForwardedToggle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
trustForwarded bool
|
||||
wantIP string
|
||||
}{
|
||||
{name: "trust disabled records proxy address", trustForwarded: false, wantIP: "127.0.0.1"},
|
||||
{name: "trust enabled records forwarded client IP", trustForwarded: true, wantIP: "1.2.3.4"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.SetTrustForwardedIPForAPIKeyACL(tc.trustForwarded)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.Use(SessionBindingContext(cfg))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
binding := service.SessionBindingFromContext(c.Request.Context())
|
||||
require.NotNil(t, binding)
|
||||
require.Equal(t, tc.wantIP, binding.IP)
|
||||
require.Equal(t, "test-agent", binding.UserAgent)
|
||||
require.Equal(t, tc.wantIP, SecurityClientIP(c))
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未经过 SessionBindingContext 注入时(异常挂载顺序/单测直调),回退 trusted_proxies 链,
|
||||
// 等价于开关关闭时的历史行为。
|
||||
func TestSecurityClientIPFallsBackWithoutInjectedBinding(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
c.String(200, SecurityClientIP(c))
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "9.9.9.9:12345"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
require.Equal(t, "9.9.9.9", w.Body.String())
|
||||
}
|
||||
|
||||
// requestSessionBinding 优先取注入值:开关开启时校验哈希必须基于注入的转发 IP 计算,
|
||||
// 与 token 签发路径取值一致,否则同一客户端会被误判为指纹变化。
|
||||
func TestRequestSessionBindingPrefersInjectedBinding(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.SetTrustForwardedIPForAPIKeyACL(true)
|
||||
|
||||
r := gin.New()
|
||||
require.NoError(t, r.SetTrustedProxies(nil))
|
||||
r.Use(SessionBindingContext(cfg))
|
||||
r.GET("/t", func(c *gin.Context) {
|
||||
issued := &service.SessionBinding{IP: "1.2.3.4", UserAgent: "test-agent"}
|
||||
require.Equal(t, issued.Hash(), requestSessionBinding(c).Hash())
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/t", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
}
|
||||
@@ -54,8 +54,9 @@ func SetupRouter(
|
||||
|
||||
// 应用中间件
|
||||
r.Use(middleware2.RequestLogger())
|
||||
// 将可信客户端 IP + UA 注入 request context,供 token 签发路径写入会话绑定
|
||||
r.Use(middleware2.SessionBindingContext())
|
||||
// 将客户端 IP + UA 注入 request context,供 token 签发/会话绑定/审计日志统一读取。
|
||||
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关。
|
||||
r.Use(middleware2.SessionBindingContext(cfg))
|
||||
r.Use(middleware2.Logger())
|
||||
r.Use(middleware2.CORS(cfg.CORS))
|
||||
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string {
|
||||
|
||||
@@ -275,6 +275,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
|
||||
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
|
||||
users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
|
||||
users.POST("/batch-limits", h.Admin.User.BatchUpdateLimits)
|
||||
users.GET("/:id/platform-quotas", h.Admin.User.GetUserPlatformQuotas)
|
||||
users.PUT("/:id/platform-quotas", h.Admin.User.UpdateUserPlatformQuotas)
|
||||
users.POST("/:id/platform-quotas/reset", h.Admin.User.ResetUserPlatformQuotaWindow)
|
||||
@@ -296,6 +297,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates)
|
||||
groups.GET("/:id", h.Admin.Group.GetByID)
|
||||
groups.POST("", h.Admin.Group.Create)
|
||||
groups.POST("/:id/duplicate", h.Admin.Group.Duplicate)
|
||||
groups.PUT("/:id", h.Admin.Group.Update)
|
||||
groups.DELETE("/:id", h.Admin.Group.Delete)
|
||||
groups.GET("/:id/stats", h.Admin.Group.GetStats)
|
||||
|
||||
@@ -1281,11 +1281,12 @@ func (a *Account) GetGrokBaseURL() string {
|
||||
}
|
||||
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
|
||||
if a.IsGrokOAuth() {
|
||||
// Subscription traffic defaults to the supported CLI gateway. Stored
|
||||
// official-host values (written by credential creation/refresh, or
|
||||
// legacy variants) mean "not customized"; only an explicit custom-host
|
||||
// forwarding address redirects traffic.
|
||||
if baseURL == "" || xai.IsOfficialBaseURL(baseURL) {
|
||||
// Operators switch subscription traffic between the official CLI
|
||||
// gateway, the official/regional API hosts and third-party relays
|
||||
// (individual endpoints go down from time to time), so a stored
|
||||
// value is always honored as-is. Only empty or unparseable values
|
||||
// fall back to the default CLI gateway.
|
||||
if baseURL == "" || !xai.IsParseableBaseURL(baseURL) {
|
||||
return xai.DefaultCLIBaseURL
|
||||
}
|
||||
return baseURL
|
||||
@@ -1297,13 +1298,20 @@ func (a *Account) GetGrokBaseURL() string {
|
||||
}
|
||||
|
||||
// GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
|
||||
// It currently resolves the same way as text traffic; the separate accessor
|
||||
// preserves the media/text distinction at call sites.
|
||||
// The subscription CLI gateway enforces a small request-body limit that
|
||||
// rejects large Base64 media payloads, so OAuth media leaves for api.x.ai
|
||||
// whenever text traffic resolves to the CLI gateway. Every other manually
|
||||
// selected endpoint (official/regional API hosts or custom relays) serves
|
||||
// media as-is.
|
||||
func (a *Account) GetGrokMediaBaseURL() string {
|
||||
if !a.IsGrok() {
|
||||
return ""
|
||||
}
|
||||
return a.GetGrokBaseURL()
|
||||
baseURL := a.GetGrokBaseURL()
|
||||
if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) {
|
||||
return xai.DefaultBaseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func (a *Account) GetGrokAccessToken() string {
|
||||
@@ -1417,10 +1425,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
// 配置集校验。
|
||||
capability = OpenAIEndpointCapabilityChatCompletions
|
||||
case OpenAIEndpointCapabilityAlphaSearch:
|
||||
// Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用
|
||||
// OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往
|
||||
// chatgpt.com/backend-api/codex/alpha/search 会稳定 401。
|
||||
if a.Type != AccountTypeOAuth {
|
||||
// alpha/search 的转发按账号类型分流:OAuth/PAT 走
|
||||
// chatgpt.com/backend-api/codex/alpha/search,API key 走
|
||||
// {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
|
||||
// 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
|
||||
if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
}
|
||||
case OpenAIEndpointCapabilityEmbeddings:
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API default is migrated at runtime to CLI subscription proxy",
|
||||
name: "oauth stored official API endpoint is honored (manual endpoint switch)",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -186,81 +186,37 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
|
||||
"base_url": xai.DefaultBaseURL,
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API default with trailing slash is migrated at runtime",
|
||||
name: "oauth stored regional API endpoint is honored",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": xai.DefaultBaseURL + "/",
|
||||
"base_url": "https://us-west-2.api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
expected: "https://us-west-2.api.x.ai/v1",
|
||||
},
|
||||
{
|
||||
name: "oauth stored CLI proxy is honored verbatim",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API root is migrated at runtime",
|
||||
name: "oauth unparseable base_url falls back to CLI proxy",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.x.ai",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API root with canonical HTTPS port is migrated at runtime",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "HTTPS://API.X.AI:443/",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API canonical port with leading zeroes is migrated at runtime",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.x.ai:0443/v1",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API encoded version path is migrated at runtime",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.x.ai/%76%31",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy API encoded trailing slash is migrated at runtime",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.x.ai/v1%2F",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth non-default API port remains pinned to CLI proxy",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://api.x.ai:8443/v1",
|
||||
"base_url": "not a url",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
@@ -318,23 +274,23 @@ func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T
|
||||
require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL())
|
||||
}
|
||||
|
||||
func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
func TestGetGrokMediaBaseURLRedirectsCLIGatewayToOfficialAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account Account
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "oauth without base_url uses CLI subscription proxy",
|
||||
name: "oauth without base_url uses official media API",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth stored CLI proxy stays on CLI subscription proxy",
|
||||
name: "oauth stored CLI proxy is separated from the media API",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -342,10 +298,10 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
"base_url": xai.DefaultCLIBaseURL,
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth stored CLI proxy variant is canonicalized to CLI proxy",
|
||||
name: "oauth stored CLI proxy variant is canonicalized to the media API",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -353,10 +309,21 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
"base_url": "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth legacy official API is pinned to CLI proxy",
|
||||
name: "oauth unparseable base_url falls back to official media API",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "not a url",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth stored official API endpoint is honored (manual endpoint switch)",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -364,7 +331,18 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
"base_url": xai.DefaultBaseURL,
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: xai.DefaultBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth stored regional API endpoint is honored for media",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://us-west-2.api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
expected: "https://us-west-2.api.x.ai/v1",
|
||||
},
|
||||
{
|
||||
name: "oauth custom base_url redirects media traffic",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxGroupNameRunes = 100
|
||||
duplicateGroupInactiveStatus = "inactive"
|
||||
)
|
||||
|
||||
func duplicateGroupOperationID(sourceID int64, actorScope, operationKey string) string {
|
||||
operationKey = strings.TrimSpace(operationKey)
|
||||
if operationKey == "" {
|
||||
return ""
|
||||
}
|
||||
actorScope = strings.TrimSpace(actorScope)
|
||||
if actorScope == "" {
|
||||
actorScope = "admin:0"
|
||||
}
|
||||
payload := "admin.groups.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return fmt.Sprintf("%x", digest)
|
||||
}
|
||||
|
||||
func duplicateGroupName(sourceName string, copyNumber int) string {
|
||||
if copyNumber < 1 {
|
||||
copyNumber = 1
|
||||
}
|
||||
suffix := " (Copy)"
|
||||
if copyNumber > 1 {
|
||||
suffix = fmt.Sprintf(" (Copy %d)", copyNumber)
|
||||
}
|
||||
baseRunes := []rune(strings.TrimSpace(sourceName))
|
||||
maxBaseRunes := maxGroupNameRunes - len([]rune(suffix))
|
||||
if maxBaseRunes < 0 {
|
||||
maxBaseRunes = 0
|
||||
}
|
||||
if len(baseRunes) > maxBaseRunes {
|
||||
baseRunes = baseRunes[:maxBaseRunes]
|
||||
}
|
||||
return string(baseRunes) + suffix
|
||||
}
|
||||
|
||||
func cloneGroupValuePointer[T any](value *T) *T {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string][]int64, len(value))
|
||||
for model, accountIDs := range value {
|
||||
cloned[model] = append([]int64(nil), accountIDs...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig {
|
||||
cloned := value
|
||||
if value.ExactModelMappings != nil {
|
||||
cloned.ExactModelMappings = make(map[string]string, len(value.ExactModelMappings))
|
||||
for requestedModel, mappedModel := range value.ExactModelMappings {
|
||||
cloned.ExactModelMappings[requestedModel] = mappedModel
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
return &Group{
|
||||
Name: duplicateGroupName(source.Name, 1),
|
||||
Description: source.Description,
|
||||
Platform: source.Platform,
|
||||
RateMultiplier: source.RateMultiplier,
|
||||
PeakRateEnabled: source.PeakRateEnabled,
|
||||
PeakStart: source.PeakStart,
|
||||
PeakEnd: source.PeakEnd,
|
||||
PeakRateMultiplier: source.PeakRateMultiplier,
|
||||
IsExclusive: source.IsExclusive,
|
||||
Status: duplicateGroupInactiveStatus,
|
||||
DuplicateOperationID: operationID,
|
||||
SubscriptionType: source.SubscriptionType,
|
||||
DailyLimitUSD: cloneGroupValuePointer(source.DailyLimitUSD),
|
||||
WeeklyLimitUSD: cloneGroupValuePointer(source.WeeklyLimitUSD),
|
||||
MonthlyLimitUSD: cloneGroupValuePointer(source.MonthlyLimitUSD),
|
||||
DefaultValidityDays: source.DefaultValidityDays,
|
||||
AllowImageGeneration: source.AllowImageGeneration,
|
||||
AllowBatchImageGeneration: source.AllowBatchImageGeneration,
|
||||
ImageRateIndependent: source.ImageRateIndependent,
|
||||
ImageRateMultiplier: source.ImageRateMultiplier,
|
||||
ImagePrice1K: cloneGroupValuePointer(source.ImagePrice1K),
|
||||
ImagePrice2K: cloneGroupValuePointer(source.ImagePrice2K),
|
||||
ImagePrice4K: cloneGroupValuePointer(source.ImagePrice4K),
|
||||
BatchImageDiscountMultiplier: source.BatchImageDiscountMultiplier,
|
||||
BatchImageHoldMultiplier: source.BatchImageHoldMultiplier,
|
||||
VideoRateIndependent: source.VideoRateIndependent,
|
||||
VideoRateMultiplier: source.VideoRateMultiplier,
|
||||
VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P),
|
||||
VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P),
|
||||
VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P),
|
||||
WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall),
|
||||
ClaudeCodeOnly: source.ClaudeCodeOnly,
|
||||
FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID),
|
||||
FallbackGroupIDOnInvalidRequest: cloneGroupValuePointer(source.FallbackGroupIDOnInvalidRequest),
|
||||
ModelRouting: cloneGroupModelRouting(source.ModelRouting),
|
||||
ModelRoutingEnabled: source.ModelRoutingEnabled,
|
||||
MCPXMLInject: source.MCPXMLInject,
|
||||
SupportedModelScopes: append([]string(nil), source.SupportedModelScopes...),
|
||||
SortOrder: source.SortOrder,
|
||||
AllowMessagesDispatch: source.AllowMessagesDispatch,
|
||||
RequireOAuthOnly: source.RequireOAuthOnly,
|
||||
RequirePrivacySet: source.RequirePrivacySet,
|
||||
DefaultMappedModel: source.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: cloneGroupMessagesDispatchModelConfig(source.MessagesDispatchModelConfig),
|
||||
ModelsListConfig: GroupModelsListConfig{
|
||||
Enabled: source.ModelsListConfig.Enabled,
|
||||
Models: append([]string(nil), source.ModelsListConfig.Models...),
|
||||
},
|
||||
RPMLimit: source.RPMLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverDuplicateGroup performs a read-only lookup for a copy that was already
|
||||
// committed for the same actor, source group, and idempotency key.
|
||||
func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
|
||||
operationID := duplicateGroupOperationID(id, actorScope, operationKey)
|
||||
if operationID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if s.groupDuplicateRepo == nil {
|
||||
return nil, errors.New("group duplicate repository is not configured")
|
||||
}
|
||||
group, err := s.groupDuplicateRepo.FindByDuplicateOperationID(ctx, operationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find duplicate group operation: %w", err)
|
||||
}
|
||||
if group == nil {
|
||||
return nil, nil
|
||||
}
|
||||
hydrated, err := s.groupRepo.GetByID(ctx, group.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load recovered duplicate group: %w", err)
|
||||
}
|
||||
return hydrated, nil
|
||||
}
|
||||
|
||||
// DuplicateGroup creates an inactive copy of a group's configuration and exact
|
||||
// account priorities. The repository commits the group, bindings, and outbox
|
||||
// event atomically so a failed binding never leaves an orphan group.
|
||||
func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) {
|
||||
existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
source, err := s.groupRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.groupDuplicateRepo == nil {
|
||||
return nil, errors.New("group duplicate repository is not configured")
|
||||
}
|
||||
|
||||
duplicate := cloneGroupForDuplicate(source, duplicateGroupOperationID(id, actorScope, operationKey))
|
||||
for copyNumber := 1; ; copyNumber++ {
|
||||
duplicate.Name = duplicateGroupName(source.Name, copyNumber)
|
||||
duplicate.ID = 0
|
||||
duplicate.CreatedAt = time.Time{}
|
||||
duplicate.UpdatedAt = time.Time{}
|
||||
if err := s.groupDuplicateRepo.CreateFromSource(ctx, duplicate, source.ID); err == nil {
|
||||
hydrated, loadErr := s.groupRepo.GetByID(ctx, duplicate.ID)
|
||||
if loadErr != nil {
|
||||
return nil, fmt.Errorf("load duplicate group: %w", loadErr)
|
||||
}
|
||||
return hydrated, nil
|
||||
} else if !errors.Is(err, ErrGroupExists) {
|
||||
return nil, fmt.Errorf("create duplicate group: %w", err)
|
||||
}
|
||||
|
||||
// A unique conflict can be either the generated name or the operation ID.
|
||||
// Recover first; if no operation row exists, advance to the next name.
|
||||
recovered, recoverErr := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey)
|
||||
if recoverErr != nil {
|
||||
return nil, recoverErr
|
||||
}
|
||||
if recovered != nil {
|
||||
return recovered, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateGroupRepoStub struct {
|
||||
GroupRepository
|
||||
nextID int64
|
||||
groups map[int64]*Group
|
||||
names map[string]struct{}
|
||||
byOperation map[string]int64
|
||||
sourceBindings map[int64][]AccountGroup
|
||||
createdBindings map[int64][]AccountGroup
|
||||
createdFromSources []int64
|
||||
atomicCreateErr error
|
||||
}
|
||||
|
||||
func newDuplicateGroupRepoStub(source *Group) *duplicateGroupRepoStub {
|
||||
repo := &duplicateGroupRepoStub{
|
||||
nextID: 100,
|
||||
groups: make(map[int64]*Group),
|
||||
names: make(map[string]struct{}),
|
||||
byOperation: make(map[string]int64),
|
||||
sourceBindings: make(map[int64][]AccountGroup),
|
||||
createdBindings: make(map[int64][]AccountGroup),
|
||||
}
|
||||
if source != nil {
|
||||
repo.groups[source.ID] = source
|
||||
repo.names[source.Name] = struct{}{}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func cloneGroupForDuplicateTest(group *Group) *Group {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *group
|
||||
cloned.DailyLimitUSD = cloneGroupValuePointer(group.DailyLimitUSD)
|
||||
cloned.WeeklyLimitUSD = cloneGroupValuePointer(group.WeeklyLimitUSD)
|
||||
cloned.MonthlyLimitUSD = cloneGroupValuePointer(group.MonthlyLimitUSD)
|
||||
cloned.ImagePrice1K = cloneGroupValuePointer(group.ImagePrice1K)
|
||||
cloned.ImagePrice2K = cloneGroupValuePointer(group.ImagePrice2K)
|
||||
cloned.ImagePrice4K = cloneGroupValuePointer(group.ImagePrice4K)
|
||||
cloned.VideoPrice480P = cloneGroupValuePointer(group.VideoPrice480P)
|
||||
cloned.VideoPrice720P = cloneGroupValuePointer(group.VideoPrice720P)
|
||||
cloned.VideoPrice1080P = cloneGroupValuePointer(group.VideoPrice1080P)
|
||||
cloned.WebSearchPricePerCall = cloneGroupValuePointer(group.WebSearchPricePerCall)
|
||||
cloned.FallbackGroupID = cloneGroupValuePointer(group.FallbackGroupID)
|
||||
cloned.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest)
|
||||
cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting)
|
||||
cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...)
|
||||
cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig)
|
||||
cloned.ModelsListConfig.Models = append([]string(nil), group.ModelsListConfig.Models...)
|
||||
cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (r *duplicateGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) {
|
||||
group := r.groups[id]
|
||||
if group == nil {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
cloned := cloneGroupForDuplicateTest(group)
|
||||
cloned.Hydrated = true
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (r *duplicateGroupRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*Group, error) {
|
||||
id := r.byOperation[operationID]
|
||||
if id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return cloneGroupForDuplicateTest(r.groups[id]), nil
|
||||
}
|
||||
|
||||
func (r *duplicateGroupRepoStub) CreateFromSource(_ context.Context, group *Group, sourceGroupID int64) error {
|
||||
if r.atomicCreateErr != nil {
|
||||
return r.atomicCreateErr
|
||||
}
|
||||
if group.DuplicateOperationID != "" {
|
||||
if _, exists := r.byOperation[group.DuplicateOperationID]; exists {
|
||||
return ErrGroupExists
|
||||
}
|
||||
}
|
||||
if _, exists := r.names[group.Name]; exists {
|
||||
return ErrGroupExists
|
||||
}
|
||||
r.nextID++
|
||||
group.ID = r.nextID
|
||||
group.CreatedAt = time.Now().UTC()
|
||||
group.UpdatedAt = group.CreatedAt
|
||||
bindings := append([]AccountGroup(nil), r.sourceBindings[sourceGroupID]...)
|
||||
for i := range bindings {
|
||||
bindings[i].GroupID = group.ID
|
||||
}
|
||||
group.AccountCount = int64(len(bindings))
|
||||
group.ActiveAccountCount = int64(len(bindings))
|
||||
r.createdBindings[group.ID] = bindings
|
||||
r.createdFromSources = append(r.createdFromSources, sourceGroupID)
|
||||
r.names[group.Name] = struct{}{}
|
||||
r.groups[group.ID] = cloneGroupForDuplicateTest(group)
|
||||
if group.DuplicateOperationID != "" {
|
||||
r.byOperation[group.DuplicateOperationID] = group.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupDuplicateTestPointer[T any](value T) *T { return &value }
|
||||
|
||||
func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) {
|
||||
createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC)
|
||||
source := &Group{
|
||||
ID: 41,
|
||||
Name: "高级订阅",
|
||||
Description: "configuration",
|
||||
Platform: PlatformOpenAI,
|
||||
RateMultiplier: 1.75,
|
||||
PeakRateEnabled: true,
|
||||
PeakStart: "09:00",
|
||||
PeakEnd: "18:00",
|
||||
PeakRateMultiplier: 1.2,
|
||||
IsExclusive: true,
|
||||
Status: StatusActive,
|
||||
Hydrated: true,
|
||||
SubscriptionType: SubscriptionTypeSubscription,
|
||||
DailyLimitUSD: groupDuplicateTestPointer(11.0),
|
||||
WeeklyLimitUSD: groupDuplicateTestPointer(22.0),
|
||||
MonthlyLimitUSD: groupDuplicateTestPointer(33.0),
|
||||
DefaultValidityDays: 91,
|
||||
AllowImageGeneration: true,
|
||||
AllowBatchImageGeneration: true,
|
||||
ImageRateIndependent: true,
|
||||
ImageRateMultiplier: 1.4,
|
||||
ImagePrice1K: groupDuplicateTestPointer(0.01),
|
||||
ImagePrice2K: groupDuplicateTestPointer(0.02),
|
||||
ImagePrice4K: groupDuplicateTestPointer(0.04),
|
||||
BatchImageDiscountMultiplier: 0.4,
|
||||
BatchImageHoldMultiplier: 0.7,
|
||||
VideoRateIndependent: true,
|
||||
VideoRateMultiplier: 2.1,
|
||||
VideoPrice480P: groupDuplicateTestPointer(0.1),
|
||||
VideoPrice720P: groupDuplicateTestPointer(0.2),
|
||||
VideoPrice1080P: groupDuplicateTestPointer(0.3),
|
||||
WebSearchPricePerCall: groupDuplicateTestPointer(0.005),
|
||||
ClaudeCodeOnly: true,
|
||||
FallbackGroupID: groupDuplicateTestPointer(int64(7)),
|
||||
FallbackGroupIDOnInvalidRequest: groupDuplicateTestPointer(int64(8)),
|
||||
ModelRouting: map[string][]int64{"gpt-*": {13, 17}},
|
||||
ModelRoutingEnabled: true,
|
||||
MCPXMLInject: true,
|
||||
SupportedModelScopes: []string{"claude", "gemini_text"},
|
||||
SortOrder: 9,
|
||||
AllowMessagesDispatch: true,
|
||||
RequireOAuthOnly: true,
|
||||
RequirePrivacySet: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: "gpt-5.4",
|
||||
SonnetMappedModel: "gpt-5.3",
|
||||
HaikuMappedModel: "gpt-5-mini",
|
||||
ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
|
||||
},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.sourceBindings[source.ID] = []AccountGroup{
|
||||
{AccountID: 13, GroupID: source.ID, Priority: 37},
|
||||
{AccountID: 17, GroupID: source.ID, Priority: 8},
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
|
||||
|
||||
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:7", "stable-key")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, source.ID, duplicate.ID)
|
||||
require.Equal(t, "高级订阅 (Copy)", duplicate.Name)
|
||||
require.Equal(t, duplicateGroupInactiveStatus, duplicate.Status)
|
||||
require.True(t, duplicate.Hydrated, "the duplicate response is reloaded with derived counts")
|
||||
require.Equal(t, source.Description, duplicate.Description)
|
||||
require.Equal(t, source.Platform, duplicate.Platform)
|
||||
require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier)
|
||||
require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier)
|
||||
require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays)
|
||||
require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K)
|
||||
require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall)
|
||||
require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
|
||||
require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
|
||||
require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig)
|
||||
require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig)
|
||||
require.Equal(t, source.RPMLimit, duplicate.RPMLimit)
|
||||
require.EqualValues(t, 2, duplicate.AccountCount)
|
||||
require.EqualValues(t, 2, duplicate.ActiveAccountCount)
|
||||
require.NotEmpty(t, duplicate.DuplicateOperationID)
|
||||
require.Equal(t, []int64{source.ID}, repo.createdFromSources)
|
||||
require.Equal(t, []AccountGroup{
|
||||
{AccountID: 13, GroupID: duplicate.ID, Priority: 37},
|
||||
{AccountID: 17, GroupID: duplicate.ID, Priority: 8},
|
||||
}, repo.createdBindings[duplicate.ID])
|
||||
|
||||
duplicate.ModelRouting["gpt-*"][0] = 999
|
||||
duplicate.SupportedModelScopes[0] = "changed"
|
||||
duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed"
|
||||
duplicate.ModelsListConfig.Models[0] = "changed"
|
||||
*duplicate.DailyLimitUSD = 999
|
||||
require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0])
|
||||
require.Equal(t, "claude", source.SupportedModelScopes[0])
|
||||
require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"])
|
||||
require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0])
|
||||
require.Equal(t, 11.0, *source.DailyLimitUSD)
|
||||
}
|
||||
|
||||
func TestDuplicateGroupRecoversSameOperationAndScopesByAdmin(t *testing.T) {
|
||||
source := &Group{ID: 9, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
|
||||
require.NoError(t, err)
|
||||
retry, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key")
|
||||
require.NoError(t, err)
|
||||
recovered, err := svc.RecoverDuplicateGroup(ctx, source.ID, "admin:7", "same-key")
|
||||
require.NoError(t, err)
|
||||
otherAdmin, err := svc.DuplicateGroup(ctx, source.ID, "admin:8", "same-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, first.ID, retry.ID)
|
||||
require.Equal(t, first.ID, recovered.ID)
|
||||
require.NotEqual(t, first.ID, otherAdmin.ID)
|
||||
require.Equal(t, "team (Copy 2)", otherAdmin.Name)
|
||||
}
|
||||
|
||||
func TestDuplicateGroupAdvancesNameAndTruncatesUnicodeByRunes(t *testing.T) {
|
||||
source := &Group{ID: 12, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.names["team (Copy)"] = struct{}{}
|
||||
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
|
||||
|
||||
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "team (Copy 2)", duplicate.Name)
|
||||
|
||||
unicodeName := duplicateGroupName(strings.Repeat("组", 100), 23)
|
||||
require.Equal(t, maxGroupNameRunes, utf8.RuneCountInString(unicodeName))
|
||||
require.True(t, strings.HasSuffix(unicodeName, " (Copy 23)"))
|
||||
}
|
||||
|
||||
func TestDuplicateGroupAtomicCreateFailureReturnsNoCopy(t *testing.T) {
|
||||
source := &Group{ID: 15, Name: "team", Platform: PlatformAnthropic, Status: StatusActive}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.atomicCreateErr = errors.New("binding insert failed")
|
||||
svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo}
|
||||
|
||||
duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "key")
|
||||
|
||||
require.ErrorContains(t, err, "binding insert failed")
|
||||
require.Nil(t, duplicate)
|
||||
require.Len(t, repo.groups, 1)
|
||||
require.Empty(t, repo.byOperation)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type AdminService interface {
|
||||
DeleteUser(ctx context.Context, id int64) error
|
||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
||||
BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error)
|
||||
BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
|
||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
|
||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
|
||||
@@ -39,6 +40,12 @@ type AdminService interface {
|
||||
GetGroup(ctx context.Context, id int64) (*Group, error)
|
||||
GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error)
|
||||
CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error)
|
||||
// DuplicateGroup creates an inactive independent copy of a group's configuration
|
||||
// and account bindings while preserving each binding's priority.
|
||||
DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error)
|
||||
// RecoverDuplicateGroup returns a previously committed copy for an ambiguous retry.
|
||||
// It never creates a group.
|
||||
RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error)
|
||||
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
||||
DeleteGroup(ctx context.Context, id int64) error
|
||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||
@@ -582,6 +589,7 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST
|
||||
type adminServiceImpl struct {
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
groupDuplicateRepo GroupDuplicateRepository
|
||||
accountRepo AccountRepository
|
||||
accountDuplicateRepo AccountDuplicateRepository
|
||||
proxyRepo ProxyRepository
|
||||
@@ -613,7 +621,7 @@ type userGroupRateBatchReader interface {
|
||||
// NewAdminService creates a new AdminService
|
||||
func NewAdminService(
|
||||
userRepo UserRepository,
|
||||
groupRepo GroupRepository,
|
||||
groupRepo AdminGroupRepository,
|
||||
accountRepo AdminAccountRepository,
|
||||
proxyRepo ProxyRepository,
|
||||
apiKeyRepo APIKeyRepository,
|
||||
@@ -635,6 +643,7 @@ func NewAdminService(
|
||||
return &adminServiceImpl{
|
||||
userRepo: userRepo,
|
||||
groupRepo: groupRepo,
|
||||
groupDuplicateRepo: groupRepo,
|
||||
accountRepo: accountRepo,
|
||||
accountDuplicateRepo: accountRepo,
|
||||
proxyRepo: proxyRepo,
|
||||
|
||||
@@ -75,6 +75,9 @@ func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int6
|
||||
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type batchLimitsUserRepoStub struct {
|
||||
*userRepoStub
|
||||
calls int
|
||||
userIDs []int64
|
||||
concurrency *int
|
||||
rpmLimit *int
|
||||
affected int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *batchLimitsUserRepoStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
s.calls++
|
||||
s.userIDs = append([]int64(nil), userIDs...)
|
||||
s.concurrency = cloneBatchLimitValue(concurrency)
|
||||
s.rpmLimit = cloneBatchLimitValue(rpmLimit)
|
||||
return s.affected, s.err
|
||||
}
|
||||
|
||||
func cloneBatchLimitValue(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func TestAdminServiceBatchUpdateLimitsPassesOnlyProvidedFields(t *testing.T) {
|
||||
concurrency := 0
|
||||
repo := &batchLimitsUserRepoStub{
|
||||
userRepoStub: &userRepoStub{},
|
||||
affected: 2,
|
||||
}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator}
|
||||
|
||||
affected, err := service.BatchUpdateLimits(
|
||||
context.Background(),
|
||||
[]int64{3, 0, 3, 7, -1},
|
||||
&concurrency,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, affected)
|
||||
require.Equal(t, []int64{3, 7}, repo.userIDs)
|
||||
require.Equal(t, pointerToInt(0), repo.concurrency)
|
||||
require.Nil(t, repo.rpmLimit)
|
||||
require.Equal(t, []int64{3, 7}, invalidator.userIDs)
|
||||
}
|
||||
|
||||
func TestAdminServiceBatchUpdateLimitsDoesNotInvalidateCacheOnRepositoryError(t *testing.T) {
|
||||
rpmLimit := 60
|
||||
repo := &batchLimitsUserRepoStub{
|
||||
userRepoStub: &userRepoStub{},
|
||||
err: errors.New("database unavailable"),
|
||||
}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator}
|
||||
|
||||
affected, err := service.BatchUpdateLimits(context.Background(), []int64{1, 2}, nil, &rpmLimit)
|
||||
|
||||
require.EqualError(t, err, "database unavailable")
|
||||
require.Zero(t, affected)
|
||||
require.Empty(t, invalidator.userIDs)
|
||||
}
|
||||
|
||||
func TestAdminServiceBatchUpdateLimitsRequiresAField(t *testing.T) {
|
||||
repo := &batchLimitsUserRepoStub{userRepoStub: &userRepoStub{}}
|
||||
service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: &authCacheInvalidatorStub{}}
|
||||
|
||||
affected, err := service.BatchUpdateLimits(context.Background(), []int64{1}, nil, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Zero(t, affected)
|
||||
require.Zero(t, repo.calls)
|
||||
}
|
||||
|
||||
func pointerToInt(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -133,6 +133,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i
|
||||
|
||||
func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *userRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
if s.existsErr != nil {
|
||||
|
||||
@@ -119,6 +119,9 @@ func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (
|
||||
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *emailSyncRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||
|
||||
|
||||
@@ -459,6 +459,39 @@ func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs [
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
if concurrency == nil && rpmLimit == nil {
|
||||
return 0, fmt.Errorf("at least one of concurrency or rpm_limit is required")
|
||||
}
|
||||
|
||||
cleaned := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[userID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
cleaned = append(cleaned, userID)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
affected, err := s.userRepo.BatchUpdateLimits(ctx, cleaned, concurrency, rpmLimit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.authCacheInvalidator != nil {
|
||||
for _, userID := range cleaned {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
}
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -963,6 +963,9 @@ func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, in
|
||||
func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *emailBindUserRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
|
||||
@@ -249,6 +249,9 @@ func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context,
|
||||
func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
|
||||
panic("unexpected BatchAddConcurrency call")
|
||||
}
|
||||
func (r *contentModerationTestUserRepo) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
|
||||
panic("unexpected BatchUpdateLimits call")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
panic("unexpected ExistsByEmail call")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -319,7 +320,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
|
||||
}
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+token)
|
||||
upstreamReq.Header.Set("Accept", "application/json")
|
||||
if account.IsGrokOAuth() {
|
||||
if account.IsGrokOAuth() && isGrokCLIProxyTarget(targetURL) {
|
||||
applyGrokCLIHeaders(upstreamReq.Header)
|
||||
}
|
||||
if endpoint.RequiresRequestBody() {
|
||||
@@ -376,6 +377,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isGrokCLIProxyTarget(rawURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
return err == nil && strings.EqualFold(parsed.Hostname(), "cli-chat-proxy.grok.com")
|
||||
}
|
||||
|
||||
func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) {
|
||||
if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) {
|
||||
return body, contentType, nil
|
||||
|
||||
@@ -120,19 +120,36 @@ func TestGrokOAuthURLPolicy(t *testing.T) {
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
})
|
||||
|
||||
t.Run("stored official-host variant stays on CLI gateway", func(t *testing.T) {
|
||||
t.Run("stored official API endpoint is honored (manual endpoint switch)", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "HTTPS://API.X.AI:443/",
|
||||
"base_url": xai.DefaultBaseURL,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
require.Equal(t, xai.DefaultBaseURL+"/responses", target)
|
||||
})
|
||||
|
||||
t.Run("stored regional API endpoint is trusted even under restrictive allowlist", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://us-west-2.api.x.ai/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = true
|
||||
cfg.Security.URLAllowlist.UpstreamHosts = []string{"other.example.test"}
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://us-west-2.api.x.ai/v1/responses", target)
|
||||
})
|
||||
|
||||
t.Run("custom forwarding address follows operator policy", func(t *testing.T) {
|
||||
|
||||
@@ -28,6 +28,9 @@ type Group struct {
|
||||
IsExclusive bool
|
||||
Status string
|
||||
Hydrated bool // indicates the group was loaded from a trusted repository source
|
||||
// DuplicateOperationID is internal persistence metadata used only to recover
|
||||
// an already committed one-click copy. It must never be mapped to API DTOs.
|
||||
DuplicateOperationID string
|
||||
|
||||
SubscriptionType string
|
||||
DailyLimitUSD *float64
|
||||
|
||||
@@ -37,6 +37,22 @@ type GroupRepository interface {
|
||||
UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||
}
|
||||
|
||||
type GroupDuplicateRepository interface {
|
||||
// FindByDuplicateOperationID performs the read-only recovery lookup used
|
||||
// after an ambiguous idempotency-store failure.
|
||||
FindByDuplicateOperationID(ctx context.Context, operationID string) (*Group, error)
|
||||
// CreateFromSource atomically persists the group, copies the source group's
|
||||
// exact account priorities, and writes the scheduler outbox event.
|
||||
CreateFromSource(ctx context.Context, group *Group, sourceGroupID int64) error
|
||||
}
|
||||
|
||||
// AdminGroupRepository makes the group-duplication write capability an explicit
|
||||
// admin-service dependency without widening gateway-only group test doubles.
|
||||
type AdminGroupRepository interface {
|
||||
GroupRepository
|
||||
GroupDuplicateRepository
|
||||
}
|
||||
|
||||
// GroupSortOrderUpdate 分组排序更新
|
||||
type GroupSortOrderUpdate struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -668,6 +668,53 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_ResponsesCapabilityExcl
|
||||
})
|
||||
}
|
||||
|
||||
// alpha/search 调度必须同时放行 OAuth 与 APIKey 账号:v0.1.157 曾因 OAuth-only
|
||||
// 门控把 APIKey 账号从候选池剔除,纯 APIKey 分组的独立搜索请求在选号阶段就
|
||||
// 报无可用账号,Codex 网页搜索整体失效(转发层其实一直支持 APIKey 路径)。
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_AlphaSearchAllowsAPIKeyAccount(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
ctx := context.Background()
|
||||
groupID := int64(10125)
|
||||
accounts := []Account{
|
||||
{
|
||||
ID: 38001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||
cache: &schedulerTestGatewayCache{},
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||
}
|
||||
|
||||
selection, _, err := svc.SelectAccountWithSchedulerForCapability(
|
||||
ctx,
|
||||
&groupID,
|
||||
"",
|
||||
"",
|
||||
"gpt-5.6-sol",
|
||||
nil,
|
||||
OpenAIUpstreamTransportHTTPSSE,
|
||||
OpenAIEndpointCapabilityAlphaSearch,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, int64(38001), selection.Account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsGrokChatAccount(t *testing.T) {
|
||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) {
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) ||
|
||||
isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
// alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
|
||||
// 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
|
||||
@@ -505,8 +506,29 @@ func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.C
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现
|
||||
// /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型
|
||||
// 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只
|
||||
// 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而
|
||||
// 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索,
|
||||
// 请求却可能死在先被选中的 API key 账号上。
|
||||
func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool {
|
||||
if account == nil || account.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
}
|
||||
return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
|
||||
}
|
||||
|
||||
func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
|
||||
return statusCode != http.StatusUnauthorized
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed:
|
||||
// 401:工具端点的 access enforcement 不代表凭据全局失效;
|
||||
// 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。
|
||||
// 两类都只换号,不写账号错误状态。
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
|
||||
|
||||
@@ -393,8 +393,101 @@ func TestForwardAlphaSearchPATResponsesFallbackUnauthorizedDoesNotMarkAccountErr
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
// API key 上游(官方平台或第三方中转)不提供 /v1/alpha/search 时返回的
|
||||
// 404/405 必须触发换号而不是把错误透传给客户端:混合分组里 OAuth 账号可以
|
||||
// 承接搜索,请求不能死在先被选中的 API key 账号上。端点缺失也不能写账号
|
||||
// 错误状态——账号本身是健康的。
|
||||
func TestForwardAlphaSearchAPIKeyEndpointNotFoundFailsOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Not Found"}}`)),
|
||||
}}
|
||||
repo := &alphaSearchAccountStateRepo{}
|
||||
cfg := &config.Config{}
|
||||
service := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 9,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://relay.example",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusNotFound, failoverErr.StatusCode)
|
||||
require.Zero(t, repo.setErrorCalls)
|
||||
require.Empty(t, repo.lastError)
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
// OAuth 账号的 chatgpt.com 端点固定存在,404 保持原有透传行为不变。
|
||||
func TestForwardAlphaSearchOAuthNotFoundPassesThrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
|
||||
upstreamBody := `{"detail":"Not Found"}`
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 10,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
require.JSONEq(t, upstreamBody, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusUnauthorized))
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusNotFound))
|
||||
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusMethodNotAllowed))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusForbidden))
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
|
||||
}
|
||||
|
||||
func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) {
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusNotFound))
|
||||
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusMethodNotAllowed))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusBadRequest))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(oauth, http.StatusNotFound))
|
||||
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(nil, http.StatusNotFound))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -219,10 +221,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest
|
||||
// FetchCodexModelsManifest fetches the live Codex models manifest from either
|
||||
// the ChatGPT backend for OAuth accounts or a custom upstream for API key accounts.
|
||||
//
|
||||
// The response body is passed through verbatim: the manifest schema evolves
|
||||
// with Codex client releases, and interpreting it here would force the gateway
|
||||
// to chase upstream changes. Passing it through keeps the gateway
|
||||
// schema-agnostic and always reflects the account's real entitlements.
|
||||
// After validating the stable top-level envelope, the response body is passed
|
||||
// through verbatim. Model entries evolve with Codex client releases, so the
|
||||
// gateway deliberately avoids interpreting their fields and reflects the
|
||||
// account's real entitlements without chasing upstream schema changes.
|
||||
func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) {
|
||||
if account == nil {
|
||||
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required")
|
||||
@@ -461,9 +463,43 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
retryable: isRetryableCodexModelsManifestTransportError(err),
|
||||
}
|
||||
}
|
||||
if err := validateCodexModelsManifestEnvelope(body); err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
err: infraerrors.Newf(
|
||||
http.StatusBadGateway,
|
||||
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
|
||||
"codex models manifest upstream returned an invalid envelope: %v",
|
||||
err,
|
||||
),
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil
|
||||
}
|
||||
|
||||
func validateCodexModelsManifestEnvelope(body []byte) error {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("decode JSON object: %w", err)
|
||||
}
|
||||
if envelope == nil {
|
||||
return errors.New("expected a JSON object")
|
||||
}
|
||||
models, ok := envelope["models"]
|
||||
if !ok {
|
||||
return errors.New("missing top-level models array")
|
||||
}
|
||||
models = bytes.TrimSpace(models)
|
||||
var entries []json.RawMessage
|
||||
if len(models) == 0 || models[0] != '[' {
|
||||
return errors.New("top-level models field is not an array")
|
||||
}
|
||||
if err := json.Unmarshal(models, &entries); err != nil {
|
||||
return fmt.Errorf("decode top-level models array: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string {
|
||||
hasher := sha256.New()
|
||||
_, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url)
|
||||
|
||||
@@ -470,6 +470,81 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "OpenAI models list", body: `{"object":"list","data":[]}`},
|
||||
{name: "invalid JSON", body: `{"models":`},
|
||||
{name: "non-object", body: `[]`},
|
||||
{name: "null object", body: `null`},
|
||||
{name: "missing models", body: `{}`},
|
||||
{name: "models object", body: `{"models":{}}`},
|
||||
{name: "models null", body: `{"models":null}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(tt.body)),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
s := newCodexModelsAPIKeyTestService(upstream)
|
||||
_, err := s.FetchCodexModelsManifest(
|
||||
context.Background(),
|
||||
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
||||
"0.144.0",
|
||||
"",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid manifest error, got nil")
|
||||
}
|
||||
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" {
|
||||
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
||||
}
|
||||
if !IsRetryableCodexModelsManifestError(err) {
|
||||
t.Error("invalid upstream manifest must be retryable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
body := `{"object":"list","data":[]}`
|
||||
if calls.Add(1) > 1 {
|
||||
body = `{"models":[{"slug":"gpt-5.6"}]}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
s := newCodexModelsAPIKeyTestService(upstream)
|
||||
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
||||
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil {
|
||||
t.Fatal("expected invalid manifest error on first fetch")
|
||||
}
|
||||
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
||||
if err != nil {
|
||||
t.Fatalf("second fetch returned error: %v", err)
|
||||
}
|
||||
if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want {
|
||||
t.Errorf("body: got %q, want %q", got, want)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("upstream calls: got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) {
|
||||
const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}`
|
||||
var calls atomic.Int32
|
||||
@@ -693,7 +768,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.
|
||||
calls.Add(1)
|
||||
body := `{"models":[]}`
|
||||
if strings.Contains(req.URL.Host, "large") {
|
||||
body = strings.Repeat("x", (1<<20)+1)
|
||||
body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
|
||||
@@ -61,6 +61,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
// Free OAuth + client function tools: reuse Messages mixed-tools cache route
|
||||
// (append web_search/x_search so xAI does not force non-cacheable build-free).
|
||||
patchedBody, err = applyGrokFreeMessagesFunctionToolCacheRoute(patchedBody, body, account, cacheIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
}
|
||||
|
||||
token, _, err := s.getRequestCredential(ctx, c, account)
|
||||
if err != nil {
|
||||
|
||||
@@ -102,8 +102,8 @@ func isGrokRequestContext(c *gin.Context) bool {
|
||||
// Free OAuth requests without native search tools are routed by xAI to the
|
||||
// non-cacheable build-free model. For otherwise tool-free requests, add the
|
||||
// native tools with tool_choice=none: this selects the cache-capable tier
|
||||
// without allowing an actual search. Explicit client tools are handled by the
|
||||
// narrower Messages-only mixed-tools policy below.
|
||||
// without allowing an actual search. Explicit client function tools are handled by
|
||||
// applyGrokFreeMessagesFunctionToolCacheRoute (Messages bridge and native Responses).
|
||||
func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if identity == "" {
|
||||
@@ -263,17 +263,35 @@ func appendMissingGrokFreeCacheNativeTools(body []byte) ([]byte, error) {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
case "function":
|
||||
if !tool.IsObject() || strings.TrimSpace(tool.Get("name").String()) == "" || tool.Get("function").Exists() {
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if !tool.IsObject() || name == "" || tool.Get("function").Exists() {
|
||||
return body, nil
|
||||
}
|
||||
// Grok Build may declare search as function tools. Convert to native
|
||||
// entries so Free OAuth stays cache-capable without duplicate names.
|
||||
if name == "web_search" || name == "x_search" {
|
||||
if present[name] {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(map[string]string{"type": name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged = append(merged, raw)
|
||||
present[name] = true
|
||||
continue
|
||||
}
|
||||
hasFunction = true
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
case "web_search", "x_search":
|
||||
// Native tools may already be present when this helper is retried.
|
||||
if present[toolType] {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
present[toolType] = true
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
merged = append(merged, json.RawMessage(tool.Raw))
|
||||
present[toolType] = true
|
||||
}
|
||||
if !hasFunction {
|
||||
return body, nil
|
||||
|
||||
@@ -288,20 +288,20 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing.
|
||||
require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestPinsOAuthOfficialVariantBaseURL(t *testing.T) {
|
||||
func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "HTTPS://API.X.AI:443/",
|
||||
"base_url": xai.DefaultBaseURL,
|
||||
},
|
||||
}
|
||||
|
||||
req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "access-token", "", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", req.URL.String())
|
||||
require.Equal(t, xai.DefaultBaseURL+"/responses", req.URL.String())
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestAppliesHeaderOverridesLast(t *testing.T) {
|
||||
@@ -666,7 +666,7 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T)
|
||||
require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds)
|
||||
}
|
||||
|
||||
func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing.T) {
|
||||
func TestForwardGrokMediaOAuthImageToVideoUsesOfficialAPIForLargeBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -702,7 +702,9 @@ func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing
|
||||
|
||||
_, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/videos/generations", upstream.lastReq.URL.String())
|
||||
require.Equal(t, xai.DefaultBaseURL+"/videos/generations", upstream.lastReq.URL.String())
|
||||
require.Empty(t, upstream.lastReq.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.image_url").String())
|
||||
}
|
||||
|
||||
|
||||
@@ -649,6 +649,12 @@ func TestNormalizeCompletedImageGenerationStatus(t *testing.T) {
|
||||
want: `{"type":"response.output_item.added","item":{"type":"image_generation_call","status":"generating","result":"image-data"}}`,
|
||||
wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "done preserves base64 result",
|
||||
input: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`,
|
||||
want: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"completed","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`,
|
||||
wantChanged: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -475,7 +475,7 @@ func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(
|
||||
}
|
||||
|
||||
func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
t.Run("OpenAI APIKey 默认兼容 chat 和 embeddings", func(t *testing.T) {
|
||||
t.Run("OpenAI APIKey 默认兼容 chat、embeddings 和 alpha search", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
@@ -483,6 +483,7 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("OpenAI OAuth 默认仅兼容 chat", func(t *testing.T) {
|
||||
@@ -496,7 +497,9 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
t.Run("alpha search 仅允许 OpenAI OAuth/PAT 类账号", func(t *testing.T) {
|
||||
t.Run("alpha search 允许 OpenAI OAuth/PAT 与 APIKey 账号,拒绝 Grok", func(t *testing.T) {
|
||||
// OAuth/PAT 走 chatgpt.com Codex 端点,APIKey 走 {base_url}/v1/alpha/search,
|
||||
// 两类都能承接独立搜索(APIKey 被排除曾导致纯 APIKey 分组搜索失效的回归)。
|
||||
apiKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
@@ -505,9 +508,14 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
grok := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
|
||||
require.False(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.True(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.True(t, oauth.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, grok.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
})
|
||||
|
||||
t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) {
|
||||
@@ -533,7 +541,8 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
// chat 能力隐含放行 alpha search(OAuth/APIKey 语义一致)。
|
||||
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
})
|
||||
|
||||
|
||||
@@ -803,6 +803,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
wroteDownstream,
|
||||
)
|
||||
}
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed {
|
||||
upstreamMessage = normalized
|
||||
}
|
||||
|
||||
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
|
||||
if responseID == "" && eventResponseID != "" {
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT
|
||||
|
||||
captureConn := &openAIWSCaptureConn{
|
||||
events: [][]byte{
|
||||
[]byte(`{"type":"response.output_item.done","item":{"id":"ig_ingress_1","type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}}`),
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
||||
},
|
||||
@@ -138,6 +139,10 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT
|
||||
}
|
||||
|
||||
writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false}`)
|
||||
firstTurnImageEvent := readMessage()
|
||||
require.Equal(t, "response.output_item.done", gjson.GetBytes(firstTurnImageEvent, "type").String())
|
||||
require.Equal(t, "completed", gjson.GetBytes(firstTurnImageEvent, "item.status").String())
|
||||
require.Equal(t, "iVBORw0KGgoAAAANSUhEUg/+==", gjson.GetBytes(firstTurnImageEvent, "item.result").String())
|
||||
firstTurnEvent := readMessage()
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(firstTurnEvent, "type").String())
|
||||
require.Equal(t, "resp_ingress_turn_1", gjson.GetBytes(firstTurnEvent, "response.id").String())
|
||||
@@ -765,7 +770,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR
|
||||
|
||||
upstreamConn := &openAIWSCaptureConn{
|
||||
events: [][]byte{
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":3}}}`),
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","output":[{"id":"ig_passthrough_1","type":"image_generation_call","status":"generating","result":"final-image"}],"usage":{"input_tokens":2,"output_tokens":3}}}`),
|
||||
},
|
||||
}
|
||||
captureDialer := &openAIWSCaptureDialer{conn: upstreamConn}
|
||||
@@ -858,6 +863,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
|
||||
require.Equal(t, "resp_passthrough_turn_1", gjson.GetBytes(event, "response.id").String())
|
||||
require.Equal(t, "completed", gjson.GetBytes(event, "response.output.0.status").String())
|
||||
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
|
||||
|
||||
select {
|
||||
@@ -1047,7 +1053,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_bridge_1"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" +
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http_bridge_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" +
|
||||
"data: {\"type\":\"response.done\",\"response\":{\"id\":\"resp_http_bridge_1\",\"output\":[{\"id\":\"ig_bridge_1\",\"type\":\"image_generation_call\",\"status\":\"in_progress\",\"result\":\"final-image\"}],\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
},
|
||||
@@ -1145,8 +1151,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
|
||||
_, event2, readErr2 := clientConn.Read(readCtx2)
|
||||
cancelRead2()
|
||||
require.NoError(t, readErr2)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(event2, "type").String())
|
||||
require.Equal(t, "response.done", gjson.GetBytes(event2, "type").String())
|
||||
require.Equal(t, "resp_http_bridge_1", gjson.GetBytes(event2, "response.id").String())
|
||||
require.Equal(t, "completed", gjson.GetBytes(event2, "response.output.0.status").String())
|
||||
|
||||
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi
|
||||
"item": map[string]any{
|
||||
"id": "ig_ws_1",
|
||||
"type": "image_generation_call",
|
||||
"status": "generating",
|
||||
"result": "final-image",
|
||||
},
|
||||
}); err != nil {
|
||||
@@ -296,6 +297,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi
|
||||
map[string]any{
|
||||
"id": "ig_ws_1",
|
||||
"type": "image_generation_call",
|
||||
"status": "in_progress",
|
||||
"result": "final-image",
|
||||
},
|
||||
},
|
||||
@@ -375,6 +377,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi
|
||||
require.Equal(t, 4, result.Usage.OutputTokens)
|
||||
require.True(t, result.OpenAIWSMode)
|
||||
require.Equal(t, "resp_ws_image_1", gjson.GetBytes(rec.Body.Bytes(), "id").String())
|
||||
require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "output.0.status").String())
|
||||
}
|
||||
|
||||
func requestToJSONString(payload map[string]any) string {
|
||||
|
||||
@@ -504,6 +504,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "")
|
||||
return nil, fmt.Errorf("openai ws read event: %w", readErr)
|
||||
}
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(message); changed {
|
||||
message = normalized
|
||||
}
|
||||
|
||||
eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message)
|
||||
if eventType == "" {
|
||||
|
||||
@@ -201,6 +201,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok prompt cache identity: %w", err)
|
||||
}
|
||||
body, err = applyGrokFreeMessagesFunctionToolCacheRoute(body, grokIntentSourceBody, account, grokCacheIdentity)
|
||||
if err != nil {
|
||||
releaseUpstreamCtx()
|
||||
return nil, fmt.Errorf("apply grok Free function-tool cache route: %w", err)
|
||||
}
|
||||
upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg)
|
||||
} else {
|
||||
upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
|
||||
@@ -331,6 +336,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
|
||||
upstreamMessage := []byte(trimmedData)
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed {
|
||||
upstreamMessage = normalized
|
||||
}
|
||||
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
|
||||
if responseID == "" && eventResponseID != "" {
|
||||
responseID = eventResponseID
|
||||
|
||||
@@ -212,6 +212,11 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if msgType == coderws.MessageText {
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(payload); changed {
|
||||
payload = normalized
|
||||
}
|
||||
}
|
||||
return c.conn.Write(ctx, msgType, payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ type UserRepository interface {
|
||||
UpdateConcurrency(ctx context.Context, id int64, amount int) error
|
||||
BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
|
||||
BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
|
||||
BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
|
||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
|
||||
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
|
||||
|
||||
@@ -208,7 +208,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int
|
||||
|
||||
func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||
func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
|
||||
out := make([]UserAuthIdentityRecord, len(m.identities))
|
||||
copy(out, m.identities)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Persist an internal operation identity for safely recovering an already
|
||||
-- committed group duplicate when the idempotency response write is ambiguous.
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS duplicate_operation_id VARCHAR(64);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_duplicate_operation_id_active
|
||||
ON groups (duplicate_operation_id)
|
||||
WHERE duplicate_operation_id IS NOT NULL AND deleted_at IS NULL;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/groups'
|
||||
|
||||
describe('admin group duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const group = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' })
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses the operation key after a page reload', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(77)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.resetModules()
|
||||
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
|
||||
const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups')
|
||||
await duplicateAfterReload(77)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reuse an operation key across administrators for the same group', async () => {
|
||||
post.mockRejectedValueOnce(new Error('first admin timeout'))
|
||||
await expect(duplicate(55)).rejects.toThrow('first admin timeout')
|
||||
const firstAdminHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'22222222-2222-4222-8222-222222222222'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } })
|
||||
await duplicate(55)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders)
|
||||
expect(post.mock.calls[1][2].headers).toEqual({
|
||||
'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe(
|
||||
firstAdminHeaders['Idempotency-Key']
|
||||
)
|
||||
expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not persist or reuse keys when the current user cannot be parsed', async () => {
|
||||
localStorage.setItem('auth_user', '{invalid json')
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(66)).rejects.toThrow('network timeout')
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
|
||||
vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce(
|
||||
'33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } })
|
||||
await duplicate(66)
|
||||
|
||||
expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,12 @@ vi.mock('@/api/client', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
batchUpdateLimits,
|
||||
bindUserAuthIdentity,
|
||||
type AdminBindAuthIdentityRequest,
|
||||
type AdminBoundAuthIdentity,
|
||||
type BatchUpdateUserLimitsRequest,
|
||||
type BatchUpdateUserLimitsResponse,
|
||||
} from '@/api/admin/users'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
@@ -63,6 +66,20 @@ const requestContractExact: Assert<
|
||||
const responseContractExact: Assert<
|
||||
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
|
||||
> = true
|
||||
const batchRequestContractExact: Assert<
|
||||
IsExact<
|
||||
BatchUpdateUserLimitsRequest,
|
||||
{
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
>
|
||||
> = true
|
||||
const batchResponseContractExact: Assert<
|
||||
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
|
||||
> = true
|
||||
|
||||
describe('admin users api auth identity binding', () => {
|
||||
beforeEach(() => {
|
||||
@@ -114,4 +131,20 @@ describe('admin users api auth identity binding', () => {
|
||||
expect(requestContractExact).toBe(true)
|
||||
expect(responseContractExact).toBe(true)
|
||||
})
|
||||
|
||||
it('posts batch limit updates once with only the supplied limit fields', async () => {
|
||||
const request: BatchUpdateUserLimitsRequest = {
|
||||
user_ids: [4, 7],
|
||||
all: false,
|
||||
rpm_limit: 0,
|
||||
}
|
||||
post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse })
|
||||
|
||||
const result = await batchUpdateLimits(request)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request)
|
||||
expect(result).toEqual({ affected: 2 })
|
||||
expect(batchRequestContractExact).toBe(true)
|
||||
expect(batchResponseContractExact).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise<AdminGroup>
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a group on the server so configuration that is not present in the
|
||||
* list response is preserved. Keep the operation key after ambiguous failures
|
||||
* so a retry replays the original operation instead of creating another group.
|
||||
*/
|
||||
const duplicateOperationKeys = new Map<string, string>()
|
||||
|
||||
interface DuplicateOperationScope {
|
||||
adminID: string
|
||||
key: string
|
||||
}
|
||||
|
||||
function getCurrentAdminID(): string | null {
|
||||
try {
|
||||
const rawUser = globalThis.localStorage?.getItem('auth_user')
|
||||
if (!rawUser) return null
|
||||
|
||||
const user: unknown = JSON.parse(rawUser)
|
||||
if (typeof user !== 'object' || user === null) return null
|
||||
|
||||
const id = (user as { id?: unknown }).id
|
||||
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
|
||||
return String(id)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateOperationScope(id: number): DuplicateOperationScope | null {
|
||||
const adminID = getCurrentAdminID()
|
||||
if (!adminID) return null
|
||||
|
||||
return {
|
||||
adminID,
|
||||
key: `sub2api:admin:group-duplicate:${adminID}:${id}`
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredDuplicateOperationKey(storageKey: string): string | null {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(storageKey) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeDuplicateOperationKey(storageKey: string, key: string | null): void {
|
||||
try {
|
||||
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
|
||||
else globalThis.sessionStorage?.removeItem(storageKey)
|
||||
} catch {
|
||||
// In-memory retry protection still works when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicate(id: number): Promise<AdminGroup> {
|
||||
const scope = duplicateOperationScope(id)
|
||||
let idempotencyKey = scope
|
||||
? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key)
|
||||
: null
|
||||
if (!idempotencyKey) {
|
||||
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}`
|
||||
}
|
||||
if (scope) {
|
||||
duplicateOperationKeys.set(scope.key, idempotencyKey)
|
||||
storeDuplicateOperationKey(scope.key, idempotencyKey)
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<AdminGroup>(`/admin/groups/${id}/duplicate`, undefined, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey }
|
||||
})
|
||||
|
||||
if (scope) {
|
||||
duplicateOperationKeys.delete(scope.key)
|
||||
storeDuplicateOperationKey(scope.key, null)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group
|
||||
* @param id - Group ID
|
||||
@@ -337,6 +417,7 @@ export const groupsAPI = {
|
||||
getById,
|
||||
getModelsListCandidates,
|
||||
create,
|
||||
duplicate,
|
||||
update,
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface AdminBoundAuthIdentity {
|
||||
channel?: AdminBoundAuthIdentityChannel | null
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsRequest {
|
||||
user_ids: number[]
|
||||
all?: boolean
|
||||
concurrency?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
|
||||
export interface BatchUpdateUserLimitsResponse {
|
||||
affected: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
@@ -184,6 +195,17 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
/** Overwrite concurrency and/or RPM limits for multiple users in one request. */
|
||||
export async function batchUpdateLimits(
|
||||
request: BatchUpdateUserLimitsRequest
|
||||
): Promise<BatchUpdateUserLimitsResponse> {
|
||||
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
|
||||
'/admin/users/batch-limits',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user status
|
||||
* @param id - User ID
|
||||
@@ -385,6 +407,7 @@ export const usersAPI = {
|
||||
delete: deleteUser,
|
||||
updateBalance,
|
||||
updateConcurrency,
|
||||
batchUpdateLimits,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
getUserUsageStats,
|
||||
|
||||
@@ -110,6 +110,11 @@
|
||||
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||
aria-labelledby="bulk-edit-base-url-label"
|
||||
/>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="allTargetsGrok"
|
||||
class="mt-2"
|
||||
@select="baseUrl = $event; enableBaseUrl = true"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
|
||||
</p>
|
||||
@@ -1221,6 +1226,7 @@ import {
|
||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||
type HeaderOverrideRow
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
OPENAI_WS_MODE_OFF,
|
||||
@@ -1260,6 +1266,12 @@ const targetMode = computed(() => props.target?.mode ?? 'selected')
|
||||
const targetPreviewCount = computed(() => props.target?.previewCount ?? props.accountIds.length)
|
||||
const targetSelectedPlatforms = computed(() => props.target?.selectedPlatforms ?? props.selectedPlatforms)
|
||||
const targetSelectedTypes = computed(() => props.target?.selectedTypes ?? props.selectedTypes)
|
||||
// Grok 快捷端点仅在所选账号全部为 grok 平台时展示(其他平台不显示)
|
||||
const allTargetsGrok = computed(
|
||||
() =>
|
||||
targetSelectedPlatforms.value.length > 0 &&
|
||||
targetSelectedPlatforms.value.every((p) => p === 'grok')
|
||||
)
|
||||
const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1)
|
||||
|
||||
const allOpenAIPassthroughCapable = computed(() => {
|
||||
|
||||
@@ -1117,6 +1117,11 @@
|
||||
"
|
||||
/>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="form.platform === 'grok'"
|
||||
class="mt-2"
|
||||
@select="apiKeyBaseUrl = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1933,7 +1938,7 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled">
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled" class="space-y-2">
|
||||
<input
|
||||
v-model="grokOAuthBaseUrl"
|
||||
type="text"
|
||||
@@ -1941,6 +1946,7 @@
|
||||
data-testid="grok-custom-base-url-input"
|
||||
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
|
||||
/>
|
||||
<GrokBaseUrlPresets @select="grokOAuthBaseUrl = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3519,6 +3525,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
"
|
||||
/>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="account.platform === 'grok'"
|
||||
class="mt-2"
|
||||
@select="editBaseUrl = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||
@@ -452,7 +457,7 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled">
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled" class="space-y-2">
|
||||
<input
|
||||
v-model="grokOAuthBaseUrl"
|
||||
type="text"
|
||||
@@ -460,6 +465,7 @@
|
||||
data-testid="grok-custom-base-url-input"
|
||||
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
|
||||
/>
|
||||
<GrokBaseUrlPresets @select="grokOAuthBaseUrl = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1455,7 +1461,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex 图片工具统一策略(自动注入 + 客户端显式携带) -->
|
||||
<!-- OpenAI Codex hosted image_generation bridge policy -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
@@ -2584,6 +2590,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in GROK_BASE_URL_PRESETS"
|
||||
:key="preset.url"
|
||||
type="button"
|
||||
data-testid="grok-base-url-preset"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-primary-50 hover:text-primary-700 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-primary-900/30 dark:hover:text-primary-400"
|
||||
@click="emit('select', preset.url)"
|
||||
>
|
||||
{{ presetLabel(preset) }} ({{ displayUrl(preset.url) }})
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { GROK_BASE_URL_PRESETS, type GrokBaseUrlPreset } from './credentialsBuilder'
|
||||
|
||||
// Grok 快捷端点:点击把预设地址填入调用方的输入框。
|
||||
// 仅是快速填充,不限制可填值——输入框仍接受任意第三方转发地址。
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', url: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const presetLabel = (preset: GrokBaseUrlPreset) =>
|
||||
preset.label ?? t(`admin.accounts.grokCustomBaseUrl.presets.${preset.labelKey}`)
|
||||
|
||||
const displayUrl = (url: string) => url.replace(/^https?:\/\//i, '')
|
||||
</script>
|
||||
@@ -130,6 +130,94 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('全部目标为 Grok OAuth 时,官方主机 base_url 作为手动端点切换正常提交', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['grok'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: {
|
||||
base_url: 'https://api.x.ai/v1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('所选全为 grok 时展示快捷端点,点击后填入并自动勾选 base_url', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['grok'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]')
|
||||
expect(presets.length).toBe(5)
|
||||
|
||||
// 第三个预设为区域 API (us-east-1.api.x.ai/v1)
|
||||
await presets[2].trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: {
|
||||
base_url: 'https://us-east-1.api.x.ai/v1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('所选含非 grok 平台时不展示快捷端点', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['grok', 'anthropic'],
|
||||
selectedTypes: ['apikey']
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0)
|
||||
})
|
||||
|
||||
it('全部目标为 Grok OAuth 时,第三方 base_url 正常提交', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['grok'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-base-url').setValue('https://relay.example.com/v1')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: {
|
||||
base_url: 'https://relay.example.com/v1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('混合类型选择(含 apikey)时官方主机 base_url 不拦截', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['grok'],
|
||||
selectedTypes: ['apikey', 'oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
credentials: {
|
||||
base_url: 'https://api.x.ai/v1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI 账号批量编辑可开启自动透传', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({
|
||||
updateAccountMock: vi.fn(),
|
||||
checkMixedChannelRiskMock: vi.fn(),
|
||||
authIsSimpleMode: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
get isSimpleMode() {
|
||||
return authIsSimpleMode.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
accounts: {
|
||||
update: updateAccountMock,
|
||||
checkMixedChannelRisk: checkMixedChannelRiskMock
|
||||
},
|
||||
settings: {
|
||||
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
|
||||
getSettings: vi.fn().mockResolvedValue({})
|
||||
},
|
||||
tlsFingerprintProfiles: {
|
||||
list: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
getAntigravityDefaultModelMapping: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import EditAccountModal from '../EditAccountModal.vue'
|
||||
|
||||
const BaseDialogStub = defineComponent({
|
||||
name: 'BaseDialog',
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
})
|
||||
|
||||
function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 5,
|
||||
name: 'Grok OAuth',
|
||||
notes: '',
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
credentials: {
|
||||
expires_at: '2027-01-01T00:00:00Z',
|
||||
token_type: 'Bearer',
|
||||
...credentials
|
||||
},
|
||||
credentials_status: { has_access_token: true, has_refresh_token: true },
|
||||
extra: {},
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
rate_multiplier: 1,
|
||||
status: 'active',
|
||||
group_ids: [],
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false
|
||||
} as any
|
||||
}
|
||||
|
||||
function mountModal(account: any) {
|
||||
return mount(EditAccountModal, {
|
||||
props: {
|
||||
show: true,
|
||||
account,
|
||||
proxies: [],
|
||||
groups: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
Select: true,
|
||||
Icon: true,
|
||||
ProxySelector: true,
|
||||
GroupSelector: true,
|
||||
ModelWhitelistSelector: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('EditAccountModal Grok OAuth upstream config', () => {
|
||||
beforeEach(() => {
|
||||
authIsSimpleMode.value = true
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
})
|
||||
|
||||
it('enabling the custom base URL toggle and saving persists base_url', async () => {
|
||||
const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
// 官方地址 → 开关初始为关(视同未定制)
|
||||
const toggle = wrapper.get('[data-testid="grok-custom-base-url-toggle"]')
|
||||
await toggle.trigger('click')
|
||||
|
||||
const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
|
||||
await input.setValue('https://my-relay.example.com')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com')
|
||||
})
|
||||
|
||||
it('accepts the official API host as a manual endpoint switch and persists it', async () => {
|
||||
const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-testid="grok-custom-base-url-input"]').setValue('https://api.x.ai/v1')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.credentials?.base_url).toBe('https://api.x.ai/v1')
|
||||
})
|
||||
|
||||
it('echoes a stored official API endpoint with the toggle on', async () => {
|
||||
const account = buildGrokOAuthAccount({ base_url: 'https://us-west-2.api.x.ai/v1' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('https://us-west-2.api.x.ai/v1')
|
||||
})
|
||||
|
||||
it('fills the input from an endpoint preset chip', async () => {
|
||||
const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click')
|
||||
const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]')
|
||||
expect(presets.length).toBe(5)
|
||||
|
||||
// 第二个预设为官方 API (api.x.ai/v1)
|
||||
await presets[1].trigger('click')
|
||||
const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('https://api.x.ai/v1')
|
||||
})
|
||||
|
||||
it('loads an existing custom base_url with the toggle on and keeps it on save', async () => {
|
||||
const account = buildGrokOAuthAccount({ base_url: 'https://my-relay.example.com' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('https://my-relay.example.com')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com')
|
||||
})
|
||||
|
||||
it('keeps stored header overrides intact on an untouched save', async () => {
|
||||
const account = buildGrokOAuthAccount({
|
||||
header_override_enabled: true,
|
||||
header_overrides: {
|
||||
'user-agent': 'grok-pager/0.2.93',
|
||||
'x-grok-client-identifier': 'grok-pager',
|
||||
'x-grok-client-version': '0.2.93',
|
||||
'x-xai-token-auth': 'xai-grok-cli'
|
||||
}
|
||||
})
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.credentials?.header_override_enabled).toBe(true)
|
||||
expect(payload?.credentials?.header_overrides).toEqual({
|
||||
'user-agent': 'grok-pager/0.2.93',
|
||||
'x-grok-client-identifier': 'grok-pager',
|
||||
'x-grok-client-version': '0.2.93',
|
||||
'x-xai-token-auth': 'xai-grok-cli'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -771,6 +771,10 @@ describe('EditAccountModal', () => {
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.codexImageTool')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolDesc')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolEnabledDesc')
|
||||
|
||||
await wrapper.get('button[data-testid="codex-image-tool-enabled"]').trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
@@ -809,6 +813,9 @@ describe('EditAccountModal', () => {
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlock')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlockDesc')
|
||||
|
||||
await wrapper.get('button[data-testid="codex-image-tool-block"]').trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildPlanTypeOptions,
|
||||
isCustomGrokBaseUrl,
|
||||
isHeaderOverrideCapable,
|
||||
GROK_BASE_URL_PRESETS,
|
||||
parseHeaderOverridesJson,
|
||||
planTypeDisplayLabel,
|
||||
readPlanType,
|
||||
@@ -169,11 +170,15 @@ describe('serializeHeaderOverrideRows', () => {
|
||||
})
|
||||
|
||||
describe('isCustomGrokBaseUrl', () => {
|
||||
it('treats official hosts and their variants as not customized', () => {
|
||||
expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false)
|
||||
it('treats only the default CLI gateway host as not customized', () => {
|
||||
expect(isCustomGrokBaseUrl('https://cli-chat-proxy.grok.com/v1')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('HTTPS://API.X.AI:443/')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('https://api.x.ai:8443/v1')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('HTTPS://CLI-CHAT-PROXY.GROK.COM:443/')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats manually switched official/regional endpoints as customized (must echo back)', () => {
|
||||
expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(true)
|
||||
expect(isCustomGrokBaseUrl('https://us-west-2.api.x.ai/v1')).toBe(true)
|
||||
expect(isCustomGrokBaseUrl('https://eu-west-1.api.x.ai/v1')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats empty, non-string and unparseable values as not customized', () => {
|
||||
@@ -191,6 +196,30 @@ describe('isCustomGrokBaseUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GROK_BASE_URL_PRESETS', () => {
|
||||
it('covers the CLI gateway, official API and regional endpoints', () => {
|
||||
const urls = GROK_BASE_URL_PRESETS.map((p) => p.url)
|
||||
expect(urls).toEqual([
|
||||
'https://cli-chat-proxy.grok.com/v1',
|
||||
'https://api.x.ai/v1',
|
||||
'https://us-east-1.api.x.ai/v1',
|
||||
'https://us-west-2.api.x.ai/v1',
|
||||
'https://eu-west-1.api.x.ai/v1'
|
||||
])
|
||||
for (const preset of GROK_BASE_URL_PRESETS) {
|
||||
// 每个预设要么有 i18n 标签键,要么有区域标识等字面标签
|
||||
expect(Boolean(preset.labelKey) || Boolean(preset.label)).toBe(true)
|
||||
if (preset.labelKey) {
|
||||
expect(['cli', 'official']).toContain(preset.labelKey)
|
||||
}
|
||||
}
|
||||
// 区域端点用区域标识作字面标签(us-east-1 这样的专有名词不做 i18n)
|
||||
expect(GROK_BASE_URL_PRESETS[2].label).toBe('us-east-1')
|
||||
expect(GROK_BASE_URL_PRESETS[3].label).toBe('us-west-2')
|
||||
expect(GROK_BASE_URL_PRESETS[4].label).toBe('eu-west-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHeaderOverrideRows', () => {
|
||||
it('accepts valid rows and empty placeholder rows', () => {
|
||||
expect(
|
||||
|
||||
@@ -199,12 +199,15 @@ export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string {
|
||||
|
||||
// ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ==========
|
||||
|
||||
const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com'])
|
||||
/** OAuth 账号建号/刷新默认写入的 CLI 网关 host——只有它视同"未定制"。 */
|
||||
const GROK_DEFAULT_GATEWAY_HOST = 'cli-chat-proxy.grok.com'
|
||||
|
||||
/**
|
||||
* 判断 Grok 账号存储的 base_url 是否为自定义转发地址。
|
||||
* 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐),
|
||||
* 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。
|
||||
* 判断 Grok 账号存储的 base_url 是否为主动指定的上游端点。
|
||||
* 运营方可在官方 API / 区域 API / 第三方转发地址之间手动切换(应对单端点
|
||||
* 不可用),这些值都必须回显(开关开启 + 显示地址)。仅默认 CLI 网关
|
||||
* (建号/刷新自动写入)、空值与无法解析的值视为"未定制"(与后端
|
||||
* GetGrokBaseURL 的回落语义对齐),用于 OAuth 账号编辑时决定开关初始状态。
|
||||
*/
|
||||
export function isCustomGrokBaseUrl(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
@@ -216,9 +219,29 @@ export function isCustomGrokBaseUrl(value: unknown): boolean {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase())
|
||||
return parsed.hostname.toLowerCase() !== GROK_DEFAULT_GATEWAY_HOST
|
||||
}
|
||||
|
||||
export interface GrokBaseUrlPreset {
|
||||
/** i18n 子键:admin.accounts.grokCustomBaseUrl.presets.<labelKey> */
|
||||
labelKey?: 'cli' | 'official'
|
||||
/** 字面标签(如区域标识 us-east-1),专有名词不参与 i18n */
|
||||
label?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok 快捷端点(仅供快速填充,输入框仍可自由填写任意转发地址)。
|
||||
* 官方端点偶发不可用时,运营方靠这组预设在端点间手动切换。
|
||||
*/
|
||||
export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
|
||||
{ labelKey: 'cli', url: 'https://cli-chat-proxy.grok.com/v1' },
|
||||
{ labelKey: 'official', url: 'https://api.x.ai/v1' },
|
||||
{ label: 'us-east-1', url: 'https://us-east-1.api.x.ai/v1' },
|
||||
{ label: 'us-west-2', url: 'https://us-west-2.api.x.ai/v1' },
|
||||
{ label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 将请求头覆写写入 credentials。
|
||||
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.users.bulkLimits.title')"
|
||||
width="normal"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<form id="bulk-edit-user-limits-form" class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.users.bulkLimits.selectedCount', { count: selectedIds.length }) }}
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-gray-200 border-y border-gray-200 dark:divide-dark-700 dark:border-dark-700">
|
||||
<div class="space-y-3 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label for="bulk-concurrency" class="input-label mb-0">
|
||||
{{ t('admin.users.columns.concurrency') }}
|
||||
</label>
|
||||
<Toggle
|
||||
v-model="enableConcurrency"
|
||||
:aria-label="t('admin.users.bulkLimits.enableConcurrency')"
|
||||
data-test="enable-concurrency"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="enableConcurrency"
|
||||
id="bulk-concurrency"
|
||||
v-model="concurrencyValue"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
data-test="concurrency-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 py-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label for="bulk-rpm-limit" class="input-label mb-0">
|
||||
{{ t('admin.users.form.rpmLimit') }}
|
||||
</label>
|
||||
<Toggle
|
||||
v-model="enableRPMLimit"
|
||||
:aria-label="t('admin.users.bulkLimits.enableRPMLimit')"
|
||||
data-test="enable-rpm-limit"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="enableRPMLimit">
|
||||
<input
|
||||
id="bulk-rpm-limit"
|
||||
v-model="rpmLimitValue"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
data-test="rpm-limit-input"
|
||||
/>
|
||||
<p v-if="parsedRPMLimit === 0" class="input-hint">
|
||||
{{ t('admin.users.bulkLimits.unlimited') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="hasInvalidValue" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ t('admin.users.bulkLimits.nonNegativeInteger') }}
|
||||
</p>
|
||||
<p v-if="selectionTooLarge" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ t('admin.users.bulkLimits.selectionLimit', { max: MAX_BATCH_USER_IDS }) }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="bulk-edit-user-limits-form"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSubmit"
|
||||
data-test="submit"
|
||||
>
|
||||
{{ submitting ? t('admin.users.bulkLimits.applying') : t('admin.users.bulkLimits.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { BatchUpdateUserLimitsRequest } from '@/api/admin/users'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
selectedIds: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: [affected: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const enableConcurrency = ref(false)
|
||||
const enableRPMLimit = ref(false)
|
||||
const concurrencyValue = ref<string | number>('')
|
||||
const rpmLimitValue = ref<string | number>('')
|
||||
const submitting = ref(false)
|
||||
const MAX_BATCH_USER_IDS = 500
|
||||
|
||||
const parseLimit = (value: string | number): number | null | undefined => {
|
||||
const trimmed = String(value).trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isInteger(parsed) || parsed < 0) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
const parsedConcurrency = computed(() =>
|
||||
enableConcurrency.value ? parseLimit(concurrencyValue.value) : undefined
|
||||
)
|
||||
const parsedRPMLimit = computed(() =>
|
||||
enableRPMLimit.value ? parseLimit(rpmLimitValue.value) : undefined
|
||||
)
|
||||
const hasInvalidValue = computed(() =>
|
||||
parsedConcurrency.value === null || parsedRPMLimit.value === null
|
||||
)
|
||||
const hasUpdate = computed(() =>
|
||||
(parsedConcurrency.value !== undefined && parsedConcurrency.value !== null)
|
||||
|| (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null)
|
||||
)
|
||||
const selectionTooLarge = computed(() => props.selectedIds.length > MAX_BATCH_USER_IDS)
|
||||
const canSubmit = computed(() =>
|
||||
props.selectedIds.length > 0
|
||||
&& !selectionTooLarge.value
|
||||
&& hasUpdate.value
|
||||
&& !hasInvalidValue.value
|
||||
&& !submitting.value
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
enableConcurrency.value = false
|
||||
enableRPMLimit.value = false
|
||||
concurrencyValue.value = ''
|
||||
rpmLimitValue.value = ''
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) reset()
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const request: BatchUpdateUserLimitsRequest = {
|
||||
user_ids: [...props.selectedIds],
|
||||
all: false
|
||||
}
|
||||
const fields: string[] = []
|
||||
if (parsedConcurrency.value !== undefined && parsedConcurrency.value !== null) {
|
||||
request.concurrency = parsedConcurrency.value
|
||||
fields.push(
|
||||
t('admin.users.bulkLimits.concurrencyValue', { value: parsedConcurrency.value })
|
||||
)
|
||||
}
|
||||
if (parsedRPMLimit.value !== undefined && parsedRPMLimit.value !== null) {
|
||||
request.rpm_limit = parsedRPMLimit.value
|
||||
fields.push(
|
||||
parsedRPMLimit.value === 0
|
||||
? t('admin.users.bulkLimits.rpmUnlimitedValue')
|
||||
: t('admin.users.bulkLimits.rpmValue', { value: parsedRPMLimit.value })
|
||||
)
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
t('admin.users.bulkLimits.confirm', {
|
||||
count: props.selectedIds.length,
|
||||
fields: fields.join(', ')
|
||||
})
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await adminAPI.users.batchUpdateLimits(request)
|
||||
appStore.showSuccess(
|
||||
t('admin.users.bulkLimits.success', { count: result.affected })
|
||||
)
|
||||
emit('success', result.affected)
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
appStore.showError(
|
||||
error.response?.data?.message
|
||||
|| error.response?.data?.detail
|
||||
|| t('admin.users.bulkLimits.failed')
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user