diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 68d9ebd162..5130959317 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.164 +0.1.165 diff --git a/backend/ent/group.go b/backend/ent/group.go index 4cff508698..d915740125 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -105,6 +105,8 @@ type Group struct { SortOrder int `json:"sort_order,omitempty"` // 是否允许 /v1/messages 调度到此 OpenAI 分组 AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"` + // 是否允许此 OpenAI 分组访问 Live 接口 + AllowLive bool `json:"allow_live,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall: values[i] = new(sql.NullFloat64) @@ -537,6 +539,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.AllowMessagesDispatch = value.Bool } + case group.FieldAllowLive: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_live", values[i]) + } else if value.Valid { + _m.AllowLive = value.Bool + } case group.FieldRequireOauthOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i]) @@ -826,6 +834,9 @@ func (_m *Group) String() string { builder.WriteString("allow_messages_dispatch=") builder.WriteString(fmt.Sprintf("%v", _m.AllowMessagesDispatch)) builder.WriteString(", ") + builder.WriteString("allow_live=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowLive)) + builder.WriteString(", ") builder.WriteString("require_oauth_only=") builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index cdf0bb9cd3..6ec01aed53 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -102,6 +102,8 @@ const ( FieldSortOrder = "sort_order" // FieldAllowMessagesDispatch holds the string denoting the allow_messages_dispatch field in the database. FieldAllowMessagesDispatch = "allow_messages_dispatch" + // FieldAllowLive holds the string denoting the allow_live field in the database. + FieldAllowLive = "allow_live" // FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database. FieldRequireOauthOnly = "require_oauth_only" // FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database. @@ -236,6 +238,7 @@ var Columns = []string{ FieldSupportedModelScopes, FieldSortOrder, FieldAllowMessagesDispatch, + FieldAllowLive, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -341,6 +344,8 @@ var ( DefaultSortOrder int // DefaultAllowMessagesDispatch holds the default value on creation for the "allow_messages_dispatch" field. DefaultAllowMessagesDispatch bool + // DefaultAllowLive holds the default value on creation for the "allow_live" field. + DefaultAllowLive bool // DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field. DefaultRequireOauthOnly bool // DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field. @@ -576,6 +581,11 @@ func ByAllowMessagesDispatch(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAllowMessagesDispatch, opts...).ToFunc() } +// ByAllowLive orders the results by the allow_live field. +func ByAllowLive(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowLive, opts...).ToFunc() +} + // ByRequireOauthOnly orders the results by the require_oauth_only field. func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 9320209433..4ad100573c 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -260,6 +260,11 @@ func AllowMessagesDispatch(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAllowMessagesDispatch, v)) } +// AllowLive applies equality check predicate on the "allow_live" field. It's identical to AllowLiveEQ. +func AllowLive(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowLive, v)) +} + // RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ. func RequireOauthOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) @@ -1985,6 +1990,16 @@ func AllowMessagesDispatchNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldAllowMessagesDispatch, v)) } +// AllowLiveEQ applies the EQ predicate on the "allow_live" field. +func AllowLiveEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowLive, v)) +} + +// AllowLiveNEQ applies the NEQ predicate on the "allow_live" field. +func AllowLiveNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAllowLive, v)) +} + // RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field. func RequireOauthOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index bfd104bcee..d12efb838a 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -607,6 +607,20 @@ func (_c *GroupCreate) SetNillableAllowMessagesDispatch(v *bool) *GroupCreate { return _c } +// SetAllowLive sets the "allow_live" field. +func (_c *GroupCreate) SetAllowLive(v bool) *GroupCreate { + _c.mutation.SetAllowLive(v) + return _c +} + +// SetNillableAllowLive sets the "allow_live" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate { + if v != nil { + _c.SetAllowLive(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -948,6 +962,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultAllowMessagesDispatch _c.mutation.SetAllowMessagesDispatch(v) } + if _, ok := _c.mutation.AllowLive(); !ok { + v := group.DefaultAllowLive + _c.mutation.SetAllowLive(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1101,6 +1119,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.AllowMessagesDispatch(); !ok { return &ValidationError{Name: "allow_messages_dispatch", err: errors.New(`ent: missing required field "Group.allow_messages_dispatch"`)} } + if _, ok := _c.mutation.AllowLive(); !ok { + return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)} + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1334,6 +1355,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) _node.AllowMessagesDispatch = value } + if value, ok := _c.mutation.AllowLive(); ok { + _spec.SetField(group.FieldAllowLive, field.TypeBool, value) + _node.AllowLive = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2224,6 +2249,18 @@ func (u *GroupUpsert) UpdateAllowMessagesDispatch() *GroupUpsert { return u } +// SetAllowLive sets the "allow_live" field. +func (u *GroupUpsert) SetAllowLive(v bool) *GroupUpsert { + u.Set(group.FieldAllowLive, v) + return u +} + +// UpdateAllowLive sets the "allow_live" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert { + u.SetExcluded(group.FieldAllowLive) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3193,6 +3230,20 @@ func (u *GroupUpsertOne) UpdateAllowMessagesDispatch() *GroupUpsertOne { }) } +// SetAllowLive sets the "allow_live" field. +func (u *GroupUpsertOne) SetAllowLive(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAllowLive(v) + }) +} + +// UpdateAllowLive sets the "allow_live" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowLive() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4345,6 +4396,20 @@ func (u *GroupUpsertBulk) UpdateAllowMessagesDispatch() *GroupUpsertBulk { }) } +// SetAllowLive sets the "allow_live" field. +func (u *GroupUpsertBulk) SetAllowLive(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAllowLive(v) + }) +} + +// UpdateAllowLive sets the "allow_live" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowLive() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 52383d7668..595565df35 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -822,6 +822,20 @@ func (_u *GroupUpdate) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdate { return _u } +// SetAllowLive sets the "allow_live" field. +func (_u *GroupUpdate) SetAllowLive(v bool) *GroupUpdate { + _u.mutation.SetAllowLive(v) + return _u +} + +// SetNillableAllowLive sets the "allow_live" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate { + if v != nil { + _u.SetAllowLive(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1495,6 +1509,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AllowMessagesDispatch(); ok { _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) } + if value, ok := _u.mutation.AllowLive(); ok { + _spec.SetField(group.FieldAllowLive, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -2627,6 +2644,20 @@ func (_u *GroupUpdateOne) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdate return _u } +// SetAllowLive sets the "allow_live" field. +func (_u *GroupUpdateOne) SetAllowLive(v bool) *GroupUpdateOne { + _u.mutation.SetAllowLive(v) + return _u +} + +// SetNillableAllowLive sets the "allow_live" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetAllowLive(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -3330,6 +3361,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AllowMessagesDispatch(); ok { _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) } + if value, ok := _u.mutation.AllowLive(); ok { + _spec.SetField(group.FieldAllowLive, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d8600a87c6..5d78a74338 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -938,6 +938,7 @@ var ( {Name: "supported_model_scopes", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "sort_order", Type: field.TypeInt, Default: 0}, {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, + {Name: "allow_live", Type: field.TypeBool, Default: false}, {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d0633e5425..cf9258ffa9 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -21911,6 +21911,7 @@ type GroupMutation struct { sort_order *int addsort_order *int allow_messages_dispatch *bool + allow_live *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24226,6 +24227,42 @@ func (m *GroupMutation) ResetAllowMessagesDispatch() { m.allow_messages_dispatch = nil } +// SetAllowLive sets the "allow_live" field. +func (m *GroupMutation) SetAllowLive(b bool) { + m.allow_live = &b +} + +// AllowLive returns the value of the "allow_live" field in the mutation. +func (m *GroupMutation) AllowLive() (r bool, exists bool) { + v := m.allow_live + if v == nil { + return + } + return *v, true +} + +// OldAllowLive returns the old "allow_live" 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) OldAllowLive(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowLive is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowLive requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowLive: %w", err) + } + return oldValue.AllowLive, nil +} + +// ResetAllowLive resets all changes to the "allow_live" field. +func (m *GroupMutation) ResetAllowLive() { + m.allow_live = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -24907,7 +24944,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, 51) + fields := make([]string, 0, 52) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25037,6 +25074,9 @@ func (m *GroupMutation) Fields() []string { if m.allow_messages_dispatch != nil { fields = append(fields, group.FieldAllowMessagesDispatch) } + if m.allow_live != nil { + fields = append(fields, group.FieldAllowLive) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -25155,6 +25195,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.SortOrder() case group.FieldAllowMessagesDispatch: return m.AllowMessagesDispatch() + case group.FieldAllowLive: + return m.AllowLive() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -25266,6 +25308,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldSortOrder(ctx) case group.FieldAllowMessagesDispatch: return m.OldAllowMessagesDispatch(ctx) + case group.FieldAllowLive: + return m.OldAllowLive(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -25592,6 +25636,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetAllowMessagesDispatch(v) return nil + case group.FieldAllowLive: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowLive(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -26180,6 +26231,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldAllowMessagesDispatch: m.ResetAllowMessagesDispatch() return nil + case group.FieldAllowLive: + m.ResetAllowLive() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 9e5a0119e3..58d1a5f35e 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1141,40 +1141,44 @@ func init() { groupDescAllowMessagesDispatch := groupFields[39].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) + // groupDescAllowLive is the schema descriptor for allow_live field. + groupDescAllowLive := groupFields[40].Descriptor() + // group.DefaultAllowLive holds the default value on creation for the allow_live field. + group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[40].Descriptor() + groupDescRequireOauthOnly := groupFields[41].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[41].Descriptor() + groupDescRequirePrivacySet := groupFields[42].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[42].Descriptor() + groupDescDefaultMappedModel := groupFields[43].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[43].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[44].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[44].Descriptor() + groupDescModelsListConfig := groupFields[45].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[45].Descriptor() + groupDescRpmLimit := groupFields[46].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[46].Descriptor() + groupDescMaxReasoningEffort := groupFields[47].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[47].Descriptor() + groupDescReasoningEffortMappings := groupFields[48].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index bce09c1f8a..4b6d056ef6 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -198,6 +198,9 @@ func (Group) Fields() []ent.Field { field.Bool("allow_messages_dispatch"). Default(false). Comment("是否允许 /v1/messages 调度到此 OpenAI 分组"), + field.Bool("allow_live"). + Default(false). + Comment("是否允许此 OpenAI 分组访问 Live 接口"), field.Bool("require_oauth_only"). Default(false). Comment("仅允许非 apikey 类型账号关联到此分组"), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 95bc3a0580..a62c068f21 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -913,6 +913,8 @@ type GatewayConfig struct { OpenAICompactModel string `mapstructure:"openai_compact_model"` // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` + // Live: ChatGPT Frameless Live 会话配置。 + Live GatewayLiveConfig `mapstructure:"live"` // OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置 OpenAIScheduler GatewayOpenAISchedulerConfig `mapstructure:"openai_scheduler"` // OpenAIHTTP2: OpenAI HTTP 上游协议策略(默认启用 HTTP/2,可按代理能力回退 HTTP/1.1) @@ -999,6 +1001,11 @@ type GatewayConfig struct { UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"` } +type GatewayLiveConfig struct { + // MaxSessionDurationSeconds 是 Live 会话的硬上限。 + MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"` +} + // GatewayOpenAIHTTP2Config OpenAI HTTP 上游协议配置。 // 默认启用 HTTP/2;在部分代理不兼容时按策略回退 HTTP/1.1。 type GatewayOpenAIHTTP2Config struct { @@ -2188,6 +2195,7 @@ func setDefaults() { viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") + viper.SetDefault("gateway.live.max_session_duration_seconds", 3600) // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false) @@ -3039,6 +3047,9 @@ func (c *Config) Validate() error { (c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 30) { return fmt.Errorf("gateway.openai_high_effort_first_output_timeout_seconds must be 0 or between 30-1800 seconds") } + if c.Gateway.Live.MaxSessionDurationSeconds <= 0 { + c.Gateway.Live.MaxSessionDurationSeconds = 3600 + } if strings.TrimSpace(c.Gateway.ConnectionPoolIsolation) != "" { switch c.Gateway.ConnectionPoolIsolation { case ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy: diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index ddc8c1e49a..d79b49a4a7 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -133,6 +133,7 @@ var DefaultBedrockModelMapping = map[string]string{ // Claude Fable "claude-fable-5": "anthropic.claude-fable-5", // Claude Opus + "claude-opus-5": "us.anthropic.claude-opus-5-v1", "claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1", "claude-opus-4-7": "us.anthropic.claude-opus-4-7-v1", "claude-opus-4-6-thinking": "us.anthropic.claude-opus-4-6-v1", diff --git a/backend/internal/handler/admin/account_ollama_cloud_usage_test.go b/backend/internal/handler/admin/account_ollama_cloud_usage_test.go index 89f2aa44ab..7dd7b712f8 100644 --- a/backend/internal/handler/admin/account_ollama_cloud_usage_test.go +++ b/backend/internal/handler/admin/account_ollama_cloud_usage_test.go @@ -61,7 +61,7 @@ func (r *ollamaCloudUsageHandlerTestRepo) UpdateOllamaCloudUsageSnapshot(context func (r *ollamaCloudUsageHandlerTestRepo) DisableOllamaCloudUsageAutoRefresh(context.Context, *service.Account) error { return nil } -func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) { +func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) { return nil, nil } @@ -273,4 +273,5 @@ func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code) require.Contains(t, recorder.Body.String(), `"enabled":false`) require.Contains(t, recorder.Body.String(), `"interval_minutes":60`) + require.Contains(t, recorder.Body.String(), `"debounce_minutes":1`) } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 070582663f..3646f5ab3a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -13,6 +13,7 @@ import ( 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/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -25,6 +26,16 @@ type GroupHandler struct { groupCapacityService *service.GroupCapacityService } +// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。 +func (h *GroupHandler) GetLiveCapability(c *gin.Context) { + err := liveattestation.NewProvider().Check(c.Request.Context()) + result := gin.H{"supported": err == nil} + if err != nil { + result["reason"] = err.Error() + } + response.Success(c, result) +} + type optionalLimitField struct { set bool value *float64 @@ -125,6 +136,7 @@ type CreateGroupRequest struct { SupportedModelScopes []string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + AllowLive bool `json:"allow_live"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -183,6 +195,7 @@ type UpdateGroupRequest struct { SupportedModelScopes *[]string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` + AllowLive *bool `json:"allow_live"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -499,6 +512,7 @@ func (h *GroupHandler) Create(c *gin.Context) { MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, + AllowLive: req.AllowLive, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -617,6 +631,7 @@ func (h *GroupHandler) Update(c *gin.Context) { MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, + AllowLive: req.AllowLive, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 5bbe1267e9..5e78953829 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2978,6 +2978,17 @@ func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.Use return nil } +func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + aliasExists, err := r.ExistsByEmailAlias(ctx, user.Email) + if err != nil { + return err + } + if aliasExists { + return service.ErrEmailExists + } + return r.Create(ctx, user) +} + func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { entity, err := r.client.User.Get(ctx, id) if err != nil { @@ -3185,6 +3196,20 @@ func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email stri return count > 0, err } +func (r *oauthPendingFlowUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + identity := service.NormalizeEmailForAliasDedup(email) + emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx) + if err != nil { + return false, err + } + for _, stored := range emails { + if service.NormalizeEmailForAliasDedup(stored) == identity { + return true, nil + } + } + return false, nil +} + func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/handler/batch_image_handler.go b/backend/internal/handler/batch_image_handler.go index b93c5a65ae..62990d251a 100644 --- a/backend/internal/handler/batch_image_handler.go +++ b/backend/internal/handler/batch_image_handler.go @@ -42,6 +42,9 @@ func (h *BatchImageHandler) Submit(c *gin.Context) { if !h.checkSecurityAuditBeforeSubmit(c, &req) { return } + if sessionID := service.ExtractClientSessionID(c); sessionID != "" { + req.SessionID = &sessionID + } got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key")) if err != nil { batchImageError(c, err) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 1ae9a5e790..04c811cedf 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -204,6 +204,7 @@ func groupFromServiceBase(g *service.Group) Group { FallbackGroupID: g.FallbackGroupID, FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest, AllowMessagesDispatch: g.AllowMessagesDispatch, + AllowLive: g.AllowLive, RequireOAuthOnly: g.RequireOAuthOnly, RequirePrivacySet: g.RequirePrivacySet, RPMLimit: g.RPMLimit, @@ -669,6 +670,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { MediaType: l.MediaType, UserAgent: l.UserAgent, IPAddress: l.IPAddress, + SessionID: l.SessionID, CacheTTLOverridden: l.CacheTTLOverridden, BillingMode: l.BillingMode, CreatedAt: l.CreatedAt, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 38133cff3b..d5ba4e9db6 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -132,6 +132,8 @@ type Group struct { // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + // OpenAI Live 接口开关 + AllowLive bool `json:"allow_live"` // 账号过滤控制(仅 OpenAI/Antigravity 平台有效) RequireOAuthOnly bool `json:"require_oauth_only"` @@ -521,6 +523,9 @@ type UsageLog struct { UserAgent *string `json:"user_agent"` // IPAddress is visible to the owner of the usage record. IPAddress *string `json:"ip_address,omitempty"` + // SessionID is the explicit client-provided request correlation identifier + // (e.g. the session_id / X-Session-Id headers). Omitted when absent. + SessionID *string `json:"session_id,omitempty"` // Cache TTL Override 标记 CacheTTLOverridden bool `json:"cache_ttl_overridden"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1ea81d1fb5..3d014f9f45 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -535,6 +535,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -547,6 +548,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, + SessionID: sessionID, RequestPayloadHash: requestPayloadHash, ForceCacheBilling: forceCacheBilling, APIKeyService: h.apiKeyService, @@ -970,6 +972,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey) + sessionID := service.ExtractClientSessionID(c) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -982,6 +985,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, + SessionID: sessionID, RequestPayloadHash: requestPayloadHash, ForceCacheBilling: forceCacheBilling, APIKeyService: h.apiKeyService, diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 3cfb9026e2..7061325f8a 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -298,6 +298,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -312,6 +313,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { reqLog.Error("gateway.cc.record_usage_failed", diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index bd545d2cf6..8f5d8531bb 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -276,6 +276,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, @@ -290,6 +291,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { reqLog.Error("gateway.responses.record_usage_failed", diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 7b349ae1db..40470ac418 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -532,6 +532,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ Result: result, @@ -549,6 +550,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { LongContextMultiplier: 2.0, // 超出部分双倍计费 ForceCacheBilling: forceCacheBilling, APIKeyService: h.apiKeyService, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { logger.L().With( diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 0522d689b1..fd0697884e 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -465,6 +465,7 @@ func recordGrokMediaUsage( ) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) + sessionID := service.ExtractClientSessionID(c) payloadForHash := body if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" { payloadForHash = []byte(requestID) @@ -493,6 +494,7 @@ func recordGrokMediaUsage( RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash), APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: channelUsageFields, }); err != nil { logger.L().With( diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index eca8931e87..c76b7d9fa5 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -233,6 +233,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage( ) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) + sessionID := service.ExtractClientSessionID(c) requestPayloadHash := service.HashUsageRequestPayload(body) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) @@ -252,6 +253,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage( RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel), }); err != nil { logger.L().With( diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 7477057113..39c12253b1 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -340,6 +340,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { @@ -355,6 +356,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { IPAddress: clientIP, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), CyberBlocked: cyberBlocked, }); err != nil { diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index b7bf64ce0e..3ed44b12b8 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -243,6 +243,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -257,6 +258,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { IPAddress: clientIP, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { logger.L().With( diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 1e5949f36f..761f291e90 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -627,6 +627,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 cyberBlocked := service.GetOpsCyberPolicy(c) != nil @@ -644,6 +645,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), CyberBlocked: cyberBlocked, }); err != nil { @@ -1138,6 +1140,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { @@ -1154,6 +1157,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel), CyberBlocked: cyberBlocked, }); err != nil { @@ -1862,6 +1866,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ @@ -1877,6 +1882,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMappingWS, reqModel, result.UpstreamModel), CyberBlocked: cyberBlocked, }); err != nil { @@ -2884,6 +2890,8 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey userAgent = c.GetHeader("User-Agent") clientIPStr = strings.TrimSpace(ip.GetClientIP(c)) } + // 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。 + sessionID := service.ExtractClientSessionID(c) apiKeyPrefix := "" if apiKey != nil { apiKeyPrefix = keyPrefix(apiKey.Key, 8) @@ -2940,6 +2948,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIPStr, + SessionID: sessionID, RequestPayloadHash: requestPayloadHash, APIKeyService: apiKeySvc, ChannelUsageFields: channelFields, diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 7ae070f6c6..72e2f462ad 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -91,6 +91,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { zap.Bool("stream", parsed.Stream), zap.Bool("multipart", parsed.Multipart), zap.String("capability", string(parsed.RequiredCapability)), + zap.String("img_quality", parsed.Quality), + zap.String("img_size", parsed.Size), ) if !service.GroupAllowsImageGeneration(apiKey.Group) { @@ -374,6 +376,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { if result != nil { upstreamModel = result.UpstreamModel } + sessionID := service.ExtractClientSessionID(c) h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: result, @@ -388,6 +391,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, + SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, requestModel, upstreamModel), }); err != nil { logger.L().With( diff --git a/backend/internal/handler/openai_images_failover_test.go b/backend/internal/handler/openai_images_failover_test.go index e5101d0148..8b07cb9a52 100644 --- a/backend/internal/handler/openai_images_failover_test.go +++ b/backend/internal/handler/openai_images_failover_test.go @@ -12,11 +12,14 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" 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" "github.com/tidwall/gjson" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" ) type openAIImagesFailoverAccountRepo struct { @@ -154,8 +157,10 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen ) handler.maxAccountSwitches = 10 - body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`) - req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`) + core, observedLogs := observer.New(zap.DebugLevel) + requestCtx := logger.IntoContext(context.Background(), zap.New(core)) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -173,6 +178,16 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen handler.Images(c) + accountSelectingLogs := observedLogs.FilterMessage("openai.images.account_selecting").All() + require.NotEmpty(t, accountSelectingLogs) + loggedFields := make(map[string]string) + for _, field := range accountSelectingLogs[0].Context { + loggedFields[field.Key] = field.String + } + require.Equal(t, "high", loggedFields["img_quality"]) + require.Equal(t, "1536x1024", loggedFields["img_size"]) + require.NotContains(t, loggedFields, "prompt") + require.Equal(t, []int64{1, 2}, upstream.calls()) require.Equal(t, http.StatusBadGateway, rec.Code) require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go new file mode 100644 index 0000000000..0e4a5af45b --- /dev/null +++ b/backend/internal/handler/openai_live.go @@ -0,0 +1,236 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +func (h *OpenAIGatewayHandler) Live(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform") + return + } + if !liveEnabledForAPIKey(apiKey) { + h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group") + return + } + request, err := parseLiveCallRequest(c) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String()) + reqLog := requestLogger( + c, + "handler.openai_gateway.live", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + if decision := h.checkSecurityAudit( + c, + reqLog, + apiKey, + subject, + service.ContentModerationProtocolOpenAIResponses, + model, + request.Session, + ); decision != nil && !decision.AllowNextStage { + h.openAISecurityAuditError(c, decision) + return + } + + subscription, _ := middleware2.GetSubscriptionFromContext(c) + if h.billingCacheService == nil { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Billing service unavailable") + return + } + if err := h.billingCacheService.CheckBillingEligibility( + c.Request.Context(), + apiKey.User, + apiKey, + apiKey.Group, + subscription, + service.QuotaPlatform(c.Request.Context(), apiKey), + ); err != nil { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + userRelease, acquired, err := h.concurrencyHelper.TryAcquireUserSlot( + c.Request.Context(), + subject.UserID, + subject.Concurrency, + ) + if err != nil { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live concurrency unavailable") + return + } + if !acquired { + h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached") + return + } + defer userRelease() + + identity := liveCallIdentity(c, apiKey, subject.UserID, subscription) + created, err := h.gatewayService.CreateLiveCall(c.Request.Context(), request, identity, subject.Concurrency) + if err != nil { + h.writeLiveCreateError(c, err) + return + } + c.Header("Location", liveSidebandLocation(c.FullPath(), created.CallID)) + c.Data(http.StatusOK, "application/sdp", created.SDP) +} + +func parseLiveCallRequest(c *gin.Context) (*service.LiveCallRequest, error) { + contentType := strings.ToLower(c.GetHeader("Content-Type")) + if strings.HasPrefix(contentType, "multipart/form-data") { + sdp := c.PostForm("sdp") + session := json.RawMessage(c.PostForm("session")) + request := &service.LiveCallRequest{SDP: sdp, Session: session} + if err := service.ValidateLiveCallRequest(request); err != nil { + return nil, err + } + return request, nil + } + var request service.LiveCallRequest + decoder := json.NewDecoder(c.Request.Body) + if err := decoder.Decode(&request); err != nil { + return nil, errors.New("request body must be valid JSON") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, errors.New("request body must contain one JSON object") + } + if err := service.ValidateLiveCallRequest(&request); err != nil { + return nil, err + } + return &request, nil +} + +func liveSidebandLocation(fullPath, callID string) string { + prefix := "/v1/live/" + if strings.HasPrefix(fullPath, "/backend-api/codex/") { + prefix = "/backend-api/codex/" + } + return prefix + url.PathEscape(callID) +} + +func liveCallIdentity( + c *gin.Context, + apiKey *service.APIKey, + userID int64, + subscription *service.UserSubscription, +) service.LiveCallIdentity { + var subscriptionID *int64 + if subscription != nil { + value := subscription.ID + subscriptionID = &value + } + return service.LiveCallIdentity{ + APIKeyID: apiKey.ID, + UserID: userID, + GroupID: apiKey.GroupID, + SubscriptionID: subscriptionID, + UserAgent: c.GetHeader("User-Agent"), + IPAddress: ip.GetClientIP(c), + InboundEndpoint: GetInboundEndpoint(c), + } +} + +func (h *OpenAIGatewayHandler) writeLiveCreateError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrLiveConcurrencyFull): + h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached") + case errors.Is(err, service.ErrLiveUnavailable): + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live is unavailable") + default: + var attestationErr *service.LiveAttestationUnavailableError + if errors.As(err, &attestationErr) { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", attestationErr.Error()) + return + } + var upstreamErr *service.UpstreamFailoverError + if errors.As(err, &upstreamErr) && upstreamErr.StatusCode >= 400 && upstreamErr.StatusCode < 500 { + h.errorResponse(c, upstreamErr.StatusCode, "invalid_request_error", "Live upstream rejected the request") + return + } + h.errorResponse(c, http.StatusBadGateway, "api_error", "Live upstream request failed") + } +} + +func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + if !liveEnabledForAPIKey(apiKey) { + h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group") + return + } + identity := service.LiveCallIdentity{ + APIKeyID: apiKey.ID, + UserID: subject.UserID, + GroupID: apiKey.GroupID, + } + record, err := h.gatewayService.GetLiveCallForIdentity(c.Request.Context(), c.Param("call_id"), identity) + if err != nil { + if errors.Is(err, service.ErrLiveIdentityMismatch) { + h.errorResponse(c, http.StatusForbidden, "permission_error", "Live call belongs to another identity") + return + } + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live call not found") + return + } + downstream, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + return + } + defer func() { _ = downstream.CloseNow() }() + if err := h.gatewayService.ProxyLiveSideband(c.Request.Context(), record, downstream); err != nil { + _ = downstream.Close(coderws.StatusInternalError, "live sideband closed") + return + } + _ = downstream.Close(coderws.StatusNormalClosure, "") +} + +func liveEnabledForAPIKey(apiKey *service.APIKey) bool { + return apiKey != nil && + apiKey.Group != nil && + apiKey.Group.Platform == service.PlatformOpenAI && + apiKey.Group.AllowLive +} diff --git a/backend/internal/handler/openai_live_test.go b/backend/internal/handler/openai_live_test.go new file mode 100644 index 0000000000..9df49654c8 --- /dev/null +++ b/backend/internal/handler/openai_live_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestParseLiveCallRequestMultipartPreservesSession(t *testing.T) { + gin.SetMode(gin.TestMode) + session := `{"model":"gpt-live-test","delegation":{"type":"client"},"instructions":"你好"}` + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("sdp", "v=0\r\n")) + require.NoError(t, writer.WriteField("session", session)) + require.NoError(t, writer.Close()) + + request := httptest.NewRequest("POST", "/v1/live", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + parsed, err := parseLiveCallRequest(context) + require.NoError(t, err) + require.Equal(t, "v=0\r\n", parsed.SDP) + require.JSONEq(t, session, string(parsed.Session)) + require.Equal(t, "client", jsonPathString(t, parsed.Session, "delegation", "type")) +} + +func TestParseLiveCallRequestJSONPreservesSessionWithoutDelegation(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"sdp":"v=0\\r\\n","session":{"model":"gpt-live-test","instructions":"standalone"}}` + request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + parsed, err := parseLiveCallRequest(context) + require.NoError(t, err) + require.NotContains(t, string(parsed.Session), "delegation") + require.Equal(t, "standalone", jsonPathString(t, parsed.Session, "instructions")) +} + +func TestParseLiveCallRequestRejectsInvalidJSONShape(t *testing.T) { + gin.SetMode(gin.TestMode) + testCases := []string{ + `{"session":{"type":"quicksilver"}}`, + `{"sdp":"v=0\\r\\n","session":[]}`, + `{"sdp":"v=0\\r\\n","session":null}`, + `{"sdp":"v=0\\r\\n","session":{"type":"quicksilver"}} {}`, + } + for _, body := range testCases { + request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + _, err := parseLiveCallRequest(context) + require.Error(t, err) + } +} + +func TestLiveSidebandLocationMatchesCreateRoute(t *testing.T) { + require.Equal(t, "/v1/live/call_123", liveSidebandLocation("/v1/live", "call_123")) + require.Equal( + t, + "/backend-api/codex/call_123", + liveSidebandLocation("/backend-api/codex/realtime/calls", "call_123"), + ) +} + +func TestLiveEnabledForAPIKey(t *testing.T) { + require.False(t, liveEnabledForAPIKey(nil)) + require.False(t, liveEnabledForAPIKey(&service.APIKey{})) + require.False(t, liveEnabledForAPIKey(&service.APIKey{ + Group: &service.Group{Platform: service.PlatformOpenAI}, + })) + require.False(t, liveEnabledForAPIKey(&service.APIKey{ + Group: &service.Group{Platform: service.PlatformAnthropic, AllowLive: true}, + })) + require.True(t, liveEnabledForAPIKey(&service.APIKey{ + Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true}, + })) +} + +func TestLiveAttestationErrorIsExplicit(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + + (&OpenAIGatewayHandler{}).writeLiveCreateError(context, &service.LiveAttestationUnavailableError{ + Reason: "Live attestation is only supported when Sub2API runs on macOS", + }) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Contains(t, recorder.Body.String(), "Sub2API runs on macOS") +} + +func jsonPathString(t *testing.T, raw json.RawMessage, keys ...string) string { + t.Helper() + var value any + require.NoError(t, json.Unmarshal(raw, &value)) + current := value + for _, key := range keys { + object, ok := current.(map[string]any) + require.True(t, ok) + current = object[key] + } + result, ok := current.(string) + require.True(t, ok) + return result +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 1143449f16..322e2dc043 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -26,6 +26,9 @@ type userHandlerRepoStub struct { } func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil } +func (s *userHandlerRepoStub) CreateWithEmailAliasGuard(context.Context, *service.User) error { + return nil +} func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) { cloned := *s.user return &cloned, nil @@ -97,6 +100,9 @@ func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, return 0, nil } func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (s *userHandlerRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index be8e0d5528..310f02788d 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -147,6 +147,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 4.8", CreatedAt: "2026-05-29T00:00:00Z", }, + { + ID: "claude-opus-5", + Type: "model", + DisplayName: "Claude Opus 5", + CreatedAt: "2026-07-25T00:00:00Z", + }, { ID: "claude-sonnet-5", Type: "model", diff --git a/backend/internal/platform/liveattestation/attestation.go b/backend/internal/platform/liveattestation/attestation.go new file mode 100644 index 0000000000..305e33d368 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation.go @@ -0,0 +1,17 @@ +package liveattestation + +import ( + "context" + "errors" +) + +var ( + ErrUnsupportedPlatform = errors.New("live attestation is only supported when Sub2API runs on macOS; Windows support is not implemented yet") + ErrChatGPTAppMissing = errors.New("live attestation requires the official ChatGPT app on the Sub2API server") +) + +// Provider 在发起 Live 请求前生成 ChatGPT DeviceCheck attestation。 +type Provider interface { + Check(ctx context.Context) error + Generate(ctx context.Context) (string, error) +} diff --git a/backend/internal/platform/liveattestation/attestation_darwin.go b/backend/internal/platform/liveattestation/attestation_darwin.go new file mode 100644 index 0000000000..c0d3779bfa --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_darwin.go @@ -0,0 +1,294 @@ +//go:build darwin + +package liveattestation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + chatGPTApplicationPath = "/Applications/ChatGPT.app" + attestationTimeout = 5 * time.Second +) + +type darwinProvider struct { + appSessionID string + appPaths []string +} + +type deviceSignals struct { + SchemaVersion int `json:"schemaVersion"` + PreferredLanguages []string `json:"preferredLanguages"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + ScreenSizeSum int `json:"screenSizeSum"` + ScreenScale float64 `json:"screenScale"` + AppSessionID string `json:"appSessionId"` +} + +type macOSSignals struct { + Locale string `json:"locale"` + Languages []string `json:"languages"` + Timezone string `json:"timezone"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Scale float64 `json:"scale"` +} + +func NewProvider() Provider { + paths := []string{chatGPTApplicationPath} + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + paths = append(paths, filepath.Join(home, "Applications", "ChatGPT.app")) + } + return &darwinProvider{ + appSessionID: uuid.NewString(), + appPaths: paths, + } +} + +func (p *darwinProvider) Check(ctx context.Context) error { + checkCtx, cancel := context.WithTimeout(ctx, attestationTimeout) + defer cancel() + _, _, _, err := p.resolveRuntime(checkCtx) + return err +} + +func (p *darwinProvider) resolveRuntime(ctx context.Context) (string, string, string, error) { + if runtime.GOARCH != "arm64" { + return "", "", "", errors.New("live attestation currently requires Apple Silicon; Intel macOS is not supported") + } + appPath, err := p.findApplication() + if err != nil { + return "", "", "", err + } + resourcesPath := filepath.Join(appPath, "Contents", "Resources") + nodePath := filepath.Join(resourcesPath, "cua_node", "bin", "node") + modulePath := filepath.Join(resourcesPath, "native", "devicecheck.node") + for filePath, label := range map[string]string{ + nodePath: "bundled Node.js runtime", + modulePath: "DeviceCheck native module", + } { + if info, statErr := os.Stat(filePath); statErr != nil || info.IsDir() { + return "", "", "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label) + } + } + bundleID, err := readBundleIdentifier(ctx, appPath) + if err != nil { + return "", "", "", err + } + return nodePath, modulePath, bundleID, nil +} + +func (p *darwinProvider) Generate(ctx context.Context) (string, error) { + runCtx, cancel := context.WithTimeout(ctx, attestationTimeout) + defer cancel() + nodePath, modulePath, bundleID, err := p.resolveRuntime(runCtx) + if err != nil { + return "", err + } + signals, err := p.readSignals(runCtx) + if err != nil { + return "", err + } + signalsJSON, err := json.Marshal(signals) + if err != nil { + return "", fmt.Errorf("encode Live attestation signals: %w", err) + } + + command := exec.CommandContext(runCtx, nodePath, "-e", deviceCheckScript) + command.Env = []string{ + "PATH=/usr/bin:/bin", + "SUB2API_DEVICECHECK_MODULE=" + modulePath, + "SUB2API_ATTESTATION_BUNDLE_ID=" + bundleID, + "SUB2API_ATTESTATION_SIGNALS=" + string(signalsJSON), + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + return "", errors.New("ChatGPT DeviceCheck token generation timed out") + } + reason := strings.TrimSpace(stderr.String()) + if len(reason) > 240 { + reason = reason[:240] + } + if reason == "" { + reason = err.Error() + } + return "", fmt.Errorf("ChatGPT DeviceCheck token generation failed: %s", reason) + } + header := strings.TrimSpace(stdout.String()) + if len(header) < 20 || len(header) > 16*1024 || !json.Valid([]byte(header)) { + return "", errors.New("ChatGPT DeviceCheck returned a malformed attestation") + } + return header, nil +} + +func (p *darwinProvider) findApplication() (string, error) { + for _, appPath := range p.appPaths { + info, err := os.Stat(appPath) + if err == nil && info.IsDir() { + return appPath, nil + } + } + return "", ErrChatGPTAppMissing +} + +func readBundleIdentifier(ctx context.Context, appPath string) (string, error) { + infoPlist := filepath.Join(appPath, "Contents", "Info.plist") + output, err := exec.CommandContext( + ctx, + "/usr/bin/plutil", + "-extract", + "CFBundleIdentifier", + "raw", + infoPlist, + ).Output() + if err != nil { + return "", fmt.Errorf("%w: cannot read its bundle identifier", ErrChatGPTAppMissing) + } + bundleID := strings.TrimSpace(string(output)) + if !strings.HasPrefix(bundleID, "com.openai.") { + return "", errors.New("the installed ChatGPT app has an unexpected bundle identifier") + } + return bundleID, nil +} + +func (p *darwinProvider) readSignals(ctx context.Context) (deviceSignals, error) { + const script = `ObjC.import("Foundation"); ObjC.import("AppKit"); +const screen = $.NSScreen.mainScreen; +const frame = screen.frame; +JSON.stringify({ + locale: ObjC.unwrap($.NSLocale.currentLocale.localeIdentifier), + languages: ObjC.deepUnwrap($.NSLocale.preferredLanguages), + timezone: ObjC.unwrap($.NSTimeZone.localTimeZone.name), + width: Number(frame.size.width), + height: Number(frame.size.height), + scale: Number(screen.backingScaleFactor) +})` + output, err := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", script).Output() + if err != nil { + return deviceSignals{}, fmt.Errorf("read macOS signals for Live attestation: %w", err) + } + var values macOSSignals + if err := json.Unmarshal(output, &values); err != nil { + return deviceSignals{}, fmt.Errorf("decode macOS signals for Live attestation: %w", err) + } + locale := truncateSignal(values.Locale, 64, "unknown") + languages := values.Languages + if len(languages) == 0 { + languages = []string{locale} + } + if len(languages) > 16 { + languages = languages[:16] + } + for index := range languages { + languages[index] = truncateSignal(languages[index], 64, locale) + } + scale := values.Scale + if scale <= 0 { + scale = 1 + } + return deviceSignals{ + SchemaVersion: 1, + PreferredLanguages: languages, + Locale: locale, + Timezone: truncateSignal(values.Timezone, 64, "unknown"), + ScreenSizeSum: max(0, int(values.Width+values.Height+0.5)), + ScreenScale: scale, + AppSessionID: truncateSignal(p.appSessionID, 128, uuid.NewString()), + }, nil +} + +func truncateSignal(value string, limit int, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + value = fallback + } + if len(value) > limit { + return value[:limit] + } + return value +} + +const deviceCheckScript = ` +const addon = require(process.env.SUB2API_DEVICECHECK_MODULE); +const signals = JSON.parse(process.env.SUB2API_ATTESTATION_SIGNALS); +const bundleID = process.env.SUB2API_ATTESTATION_BUNDLE_ID; + +function head(major, value) { + if (value < 24) return Buffer.from([major + value]); + if (value <= 255) return Buffer.from([major + 24, value]); + if (value <= 65535) { + const out = Buffer.allocUnsafe(3); + out[0] = major + 25; + out.writeUInt16BE(value, 1); + return out; + } + const out = Buffer.allocUnsafe(5); + out[0] = major + 26; + out.writeUInt32BE(value, 1); + return out; +} +function uint(value) { return head(0, value); } +function text(value) { + const body = Buffer.from(value, "utf8"); + return Buffer.concat([head(96, body.length), body]); +} +function float(value) { + if (Number.isSafeInteger(value) && value >= 0) return uint(value); + const out = Buffer.allocUnsafe(9); + out[0] = 251; + out.writeDoubleBE(value, 1); + return out; +} +function array(values) { return Buffer.concat([head(128, values.length), ...values]); } +function map(entries) { + return Buffer.concat([head(160, entries.length), ...entries.flatMap(([key, value]) => [uint(key), value])]); +} +function field(key, value) { return Buffer.concat([text(key), text(value)]); } +function base64url(value) { + return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +(async () => { + const result = await addon.generateToken(); + if (!result || !result.supported) throw new Error("DeviceCheck is not supported on this Mac"); + if (!result.tokenBase64) throw new Error("DeviceCheck returned no token"); + const fingerprint = map([ + [0, uint(signals.schemaVersion)], + [1, array(signals.preferredLanguages.map(text))], + [2, text(signals.locale)], + [3, text(signals.timezone)], + [4, uint(signals.screenSizeSum)], + [5, float(signals.screenScale)], + [6, text(signals.appSessionId)] + ]); + const fields = [ + field("token", result.tokenBase64), + field("bundle_id", bundleID), + Buffer.concat([text("f"), head(64, fingerprint.length), fingerprint]) + ]; + if (result.latencyMs != null) { + fields.push(Buffer.concat([text("t"), float(result.latencyMs)])); + } + const token = "v1." + base64url(Buffer.concat([Buffer.from([160 + fields.length]), ...fields])); + process.stdout.write(JSON.stringify({v: 1, s: 0, t: token})); +})().catch((error) => { + process.stderr.write(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +});` diff --git a/backend/internal/platform/liveattestation/attestation_unsupported.go b/backend/internal/platform/liveattestation/attestation_unsupported.go new file mode 100644 index 0000000000..4eebfc45d3 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_unsupported.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package liveattestation + +import "context" + +type unsupportedProvider struct{} + +func NewProvider() Provider { + return unsupportedProvider{} +} + +func (unsupportedProvider) Check(context.Context) error { + return ErrUnsupportedPlatform +} + +func (unsupportedProvider) Generate(context.Context) (string, error) { + return "", ErrUnsupportedPlatform +} diff --git a/backend/internal/platform/liveattestation/attestation_unsupported_test.go b/backend/internal/platform/liveattestation/attestation_unsupported_test.go new file mode 100644 index 0000000000..8ac8fc1082 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_unsupported_test.go @@ -0,0 +1,20 @@ +//go:build !darwin + +package liveattestation + +import ( + "context" + "errors" + "testing" +) + +func TestUnsupportedProviderReturnsExplicitPlatformError(t *testing.T) { + provider := NewProvider() + if err := provider.Check(context.Background()); !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("Check() error = %v, want ErrUnsupportedPlatform", err) + } + _, err := provider.Generate(context.Background()) + if !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("Generate() error = %v, want ErrUnsupportedPlatform", err) + } +} diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage.go b/backend/internal/repository/account_repo_ollama_cloud_usage.go index 78d14d2097..4726805ae6 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage.go @@ -354,74 +354,182 @@ func canonicalJSON(raw string) string { return string(encoded) } -// ListDueOllamaCloudUsageAccounts returns at most one due representative per -// exact API key before hydration, preventing one shared group from consuming a -// whole runner cycle. -func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { +// ollamaCloudUsageParseRFC3339SQL reuses the verified RFC3339(/Nano) parse path +// for a snapshot timestamp expression. Invalid or missing values fail open to NULL. +// +// The value is rewritten twice before it reaches jsonpath: +// 1. Sub-second precision beyond 6 digits is truncated, because .datetime() +// rejects more than microsecond resolution while Go emits 9 digits. +// 2. A trailing "Z" is rewritten to "+00:00". jsonpath .datetime() only learned +// to accept the ISO-8601 "Z" designator in PostgreSQL 17, and every timestamp +// this service writes is UTC (hence "Z"). Without this rewrite the parse +// silently yields NULL on PostgreSQL <= 16, which makes every due column NULL +// and collapses ListDueOllamaCloudUsageAccounts into its fail-open branch. +// +// jsonpath (rather than a direct ::timestamptz cast) is required so that values +// passing the shape regex but naming an impossible date (e.g. 2026-02-30) fail +// open to NULL instead of aborting the whole query. +func ollamaCloudUsageParseRFC3339SQL(expression string) string { + return `CASE + WHEN ` + expression + ` IS NULL THEN NULL + WHEN ` + expression + ` ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' + THEN jsonb_path_query_first_tz( + to_jsonb(regexp_replace( + regexp_replace( + ` + expression + `, + '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', + '\1\2' + ), + 'Z$', + '+00:00' + )), + '$.datetime()', '{}'::jsonb, true + ) #>> '{}' + ELSE NULL + END` +} + +// ListDueOllamaCloudUsageAccounts returns at most one truly-due activity-driven +// candidate per exact API key. Due timing (debounce, max-wait, failure backoff) +// is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones. +// Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service +// pure-function recheck against races between list and refresh. +// +// Rules mirror service.ollamaCloudUsageAutoRefreshDueAt (keep both in sync): +// - missing/invalid snapshot or times → fail-open first due +// - success: activity after fetched_at; +// due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval) +// - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...); +// final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open) +func (r *accountRepository) ListDueOllamaCloudUsageAccounts( + ctx context.Context, + now time.Time, + debounce, maxWait time.Duration, + limit int, +) ([]service.Account, error) { if limit <= 0 { return []service.Account{}, nil } if r == nil || r.sql == nil { return nil, errors.New("account repository SQL executor not configured") } + if debounce <= 0 { + debounce = time.Minute + } + if maxWait <= 0 { + maxWait = time.Hour + } + debounceSeconds := debounce.Seconds() + maxWaitSeconds := maxWait.Seconds() + minFetchIntervalSeconds := service.OllamaCloudUsageMinFetchInterval.Seconds() rows, err := r.sql.QueryContext(ctx, ` - WITH candidates AS ( - SELECT id, credentials ->> 'api_key' AS api_key, - extra #>> '{ollama_cloud_usage_snapshot,next_refresh_at}' AS next_refresh_at + WITH eligible AS ( + SELECT id, + credentials ->> 'api_key' AS api_key, + last_used_at, + extra -> 'ollama_cloud_usage_snapshot' AS snapshot FROM accounts WHERE deleted_at IS NULL AND status = 'active' AND `+ollamaCloudUsageEligibleSQL+` AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string' AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb + ), group_activity AS ( + SELECT credentials ->> 'api_key' AS api_key, + MAX(last_used_at) AS group_last_used_at + FROM accounts + WHERE deleted_at IS NULL + AND `+ollamaCloudUsageEligibleSQL+` + AND jsonb_typeof(credentials -> 'api_key') = 'string' + GROUP BY credentials ->> 'api_key' + ), joined AS ( + SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at, + e.snapshot #>> '{status}' AS status, + e.snapshot #>> '{fetched_at}' AS fetched_at, + e.snapshot #>> '{last_attempt_at}' AS last_attempt_at, + e.snapshot #>> '{next_refresh_at}' AS next_refresh_at + FROM eligible e + JOIN group_activity g ON g.api_key = e.api_key ), parsed AS MATERIALIZED ( - SELECT id, api_key, next_refresh_at, - next_refresh_at ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AS rfc3339_shape, - jsonb_path_query_first_tz( - to_jsonb(regexp_replace( - next_refresh_at, - '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', - '\1\2' - )), - '$.datetime()', '{}'::jsonb, true - ) #>> '{}' AS parsed_next_refresh_at - FROM candidates - ), due AS ( + SELECT id, api_key, snapshot, group_last_used_at, status, + `+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at, + `+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at, + `+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at + FROM joined + ), timed AS ( SELECT *, - CASE WHEN next_refresh_at IS NULL OR NOT rfc3339_shape OR parsed_next_refresh_at IS NULL THEN 0 ELSE 1 END AS due_class + CASE + WHEN status = 'ok' + AND parsed_fetched_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_fetched_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision) + ), + parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision) + ) + WHEN status IN ('failed', 'unauthorized') + AND parsed_last_attempt_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_last_attempt_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision) + ), + COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz) + ) + ELSE NULL + END AS activity_due_at FROM parsed - WHERE next_refresh_at IS NULL - OR NOT rfc3339_shape - OR parsed_next_refresh_at IS NULL - OR parsed_next_refresh_at::timestamptz <= $1 + ), candidates AS ( + SELECT *, + CASE + WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL + OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0 + WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0 + WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0 + WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1 + ELSE NULL + END AS due_class, + activity_due_at AS due_at + FROM timed ), ranked AS ( - SELECT *, row_number() OVER ( - PARTITION BY api_key - ORDER BY due_class, - CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST, - id - ) AS group_rank - FROM due + SELECT id, api_key, group_last_used_at, due_class, due_at, + row_number() OVER ( + PARTITION BY api_key + ORDER BY due_class, + due_at NULLS FIRST, + id + ) AS group_rank + FROM candidates + WHERE due_class IS NOT NULL ) - SELECT id + SELECT id, group_last_used_at FROM ranked WHERE group_rank = 1 - ORDER BY due_class, - CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST, - id - LIMIT $2 - `, now.UTC(), limit) + ORDER BY due_class, due_at NULLS FIRST, id + LIMIT $4 + `, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds) if err != nil { return nil, err } defer func() { _ = rows.Close() }() + type dueRow struct { + id int64 + groupLastUsed *time.Time + } + rowsOut := make([]dueRow, 0, limit) ids := make([]int64, 0, limit) for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { + var row dueRow + if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil { return nil, err } - ids = append(ids, id) + rowsOut = append(rowsOut, row) + ids = append(ids, row.id) } if err := rows.Err(); err != nil { return nil, err @@ -430,11 +538,26 @@ func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, if err != nil { return nil, err } - result := make([]service.Account, 0, len(accounts)) + byID := make(map[int64]*service.Account, len(accounts)) for _, account := range accounts { if account != nil { - result = append(result, *account) + byID[account.ID] = account } } + result := make([]service.Account, 0, len(rowsOut)) + for _, row := range rowsOut { + account := byID[row.id] + if account == nil { + continue + } + // Stamp group MAX(last_used_at) for service due evaluation. + if row.groupLastUsed != nil { + ts := row.groupLastUsed.UTC() + account.LastUsedAt = &ts + } else { + account.LastUsedAt = nil + } + result = append(result, *account) + } return result, nil } diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go index a7dc861249..448eb42a69 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go @@ -23,81 +23,199 @@ func TestListDueOllamaCloudUsageAccountsOrderingLimitAndProxyHydration(t *testin Username: "user", Password: "pass", Status: service.StatusActive, }) - createAccount := func(name, baseURL string, proxyID *int64, nextRefreshAt *time.Time) *service.Account { + createAccount := func(name, baseURL string, proxyID *int64, snapshot map[string]any, lastUsed *time.Time) *service.Account { t.Helper() extra := map[string]any{ service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", service.OllamaCloudUsageAutoRefreshExtraKey: true, } - if nextRefreshAt != nil { - extra[service.OllamaCloudUsageSnapshotExtraKey] = map[string]any{ - "status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt.UTC().Format(time.RFC3339Nano), - } + if snapshot != nil { + extra[service.OllamaCloudUsageSnapshotExtraKey] = snapshot } return mustCreateAccount(t, tx.Client(), &service.Account{ Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": name, "base_url": baseURL}, - Extra: extra, ProxyID: proxyID, + Extra: extra, ProxyID: proxyID, LastUsedAt: lastUsed, }) } - uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil) - missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil) - oldest := now.Add(-2 * time.Hour) - due := createAccount("ollama-due-oldest", "https://ollama.com", nil, &oldest) - future := now.Add(time.Minute) - _ = createAccount("ollama-not-due", "https://ollama.com", nil, &future) - _ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil) + uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil, nil) + missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil, nil) + fetched := now.Add(-2 * time.Hour) + activity := now.Add(-5 * time.Minute) + due := createAccount("ollama-due-activity", "https://ollama.com", nil, map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, &activity) + // Success snapshot without newer activity must not be listed. + _ = createAccount("ollama-not-due-idle", "https://ollama.com", nil, map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano), + "last_attempt_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano), + "next_refresh_at": now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), + }, nil) + _ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil, nil) - accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 2) + accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 2) require.NoError(t, err) require.Len(t, accounts, 2) require.Equal(t, missingSnapshot.ID, accounts[0].ID) require.Equal(t, due.ID, accounts[1].ID) + require.NotNil(t, accounts[1].LastUsedAt) + require.WithinDuration(t, activity.UTC(), accounts[1].LastUsedAt.UTC(), time.Second) require.NotContains(t, accountIDs(accounts), uppercasePath.ID) require.NotNil(t, accounts[0].Proxy) require.Equal(t, proxy.ID, accounts[0].Proxy.ID) require.Equal(t, proxy.URL(), accounts[0].Proxy.URL()) } -func TestListDueOllamaCloudUsageAccountsParsesRFC3339NanoAndFailsOpen(t *testing.T) { +// TestListDueOllamaCloudUsageAccountsParsesAllRFC3339Precisions pins the SQL +// timestamp parse path across the sub-second precisions and zone spellings that +// actually reach the database. +// +// Each fixture stores a fetched_at only two minutes old with activity 30s later, +// so a correctly parsed row is NOT due (debounce and the min fetch interval both +// place it in the future). A row whose timestamp fails to parse becomes NULL and +// falls into the fail-open branch, which makes it due. Asserting on absence is +// therefore what makes this test able to fail: +// +// - Go writes UTC times, i.e. the "Z" designator. jsonpath .datetime() only +// accepts "Z" from PostgreSQL 17 on, so without the Z -> +00:00 rewrite in +// ollamaCloudUsageParseRFC3339SQL every fixture here goes due on 14-16. +// - 7/8/9 sub-second digits exceed the microsecond resolution .datetime() +// allows and must be truncated first. +// +// Run against the oldest supported server to exercise the version-sensitive path: +// +// SUB2API_TEST_POSTGRES_IMAGE=postgres:15-alpine go test -tags integration ./internal/repository/ +func TestListDueOllamaCloudUsageAccountsParsesAllRFC3339Precisions(t *testing.T) { ctx := context.Background() tx := testEntTx(t) repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC) + activity := now.Add(-30 * time.Second) - create := func(name, nextRefreshAt string) *service.Account { - t.Helper() - return mustCreateAccount(t, tx.Client(), &service.Account{ - Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"}, + // All three spell the same instant, now-2m, with different precision/zone. + notDue := map[string]string{ + "nano-z": "2026-07-22T13:58:00.123456789Z", + "eight-positive": "2026-07-22T14:58:00.12345678+01:00", + "seven-negative": "2026-07-22T11:58:00.1234567-02:00", + } + for name, fetchedAt := range notDue { + _ = mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-precision-" + name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "precision-" + name, "base_url": "https://ollama.com"}, Extra: map[string]any{ service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", service.OllamaCloudUsageAutoRefreshExtraKey: true, service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ - "status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt, + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetchedAt, + "last_attempt_at": fetchedAt, }, }, + LastUsedAt: &activity, }) } - sevenDigitsOffset := create("ollama-nano-seven", "2026-07-22T11:00:00.1234567-02:00") - eightDigitsOffset := create("ollama-nano-eight", "2026-07-22T11:00:00.12345678+01:00") - nineDigitsZ := create("ollama-nano-nine", "2026-07-22T09:00:00.123456789Z") - invalidCalendar := create("ollama-nano-invalid", "2026-02-30T09:00:00.123456789Z") - future := create("ollama-nano-future", "2026-07-22T15:00:00.123456789Z") + // Guards against a vacuous pass: an genuinely due row must still come back. + staleFetched := now.Add(-2 * time.Hour) + due := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-precision-due", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "precision-due", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": staleFetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": staleFetched.UTC().Format(time.RFC3339Nano), + }, + }, + LastUsedAt: &activity, + }) - accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 10) + accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 10) + + require.NoError(t, err) + ids := accountIDs(accounts) + require.Contains(t, ids, due.ID, "a stale snapshot with fresh activity must be due") + require.Len(t, ids, 1, + "only the stale group may be due; extra rows mean a timestamp failed to parse and fell into the fail-open branch") +} + +func TestListDueOllamaCloudUsageAccountsUsesGroupMaxLastUsedAndFailsOpen(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + older := now.Add(-10 * time.Minute) + newer := now.Add(-2 * time.Minute) + + leader := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-group-leader", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, + }, + LastUsedAt: &older, + }) + _ = mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-group-sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://www.ollama.com/v1"}, + LastUsedAt: &newer, + }) + invalid := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-invalid-snapshot", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "invalid-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, "fetched_at": "2026-02-30T09:00:00.123456789Z", + }, + }, + }) + idle := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-idle-ok", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "idle-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano), + }, + }, + }) + + accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 10) require.NoError(t, err, "invalid stored values must not abort the query") - require.Equal(t, []int64{ - invalidCalendar.ID, - nineDigitsZ.ID, - eightDigitsOffset.ID, - sevenDigitsOffset.ID, - }, accountIDs(accounts)) - require.NotContains(t, accountIDs(accounts), future.ID) + ids := accountIDs(accounts) + require.Contains(t, ids, invalid.ID) + require.Contains(t, ids, leader.ID) + require.NotContains(t, ids, idle.ID) + for _, account := range accounts { + if account.ID == leader.ID { + require.NotNil(t, account.LastUsedAt) + require.WithinDuration(t, newer.UTC(), account.LastUsedAt.UTC(), time.Second, + "group MAX(last_used_at) must come from the sibling") + } + } } func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *testing.T) { @@ -413,3 +531,111 @@ func TestUpdateCredentialsUnchangedCredentialsPreserveManagedExtra(t *testing.T) require.NotContains(t, probeLoaded.Extra, service.UpstreamBillingProbeExtraKey, "changed credentials must keep clearing the probe snapshot") } + +// TestListDueOllamaCloudUsageAccountsSQLDueRulesMatchService proves the SQL +// candidate layer applies debounce / max-wait / failure-backoff before LIMIT, +// matching service.ollamaCloudUsageIsAutoRefreshDue, and that >20 active-but- +// not-yet-due groups cannot starve a truly due max-wait group. +func TestListDueOllamaCloudUsageAccountsSQLDueRulesMatchService(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + debounce := time.Minute + maxWait := time.Hour + + createOK := func(name string, fetched, lastUsed time.Time) *service.Account { + t.Helper() + return mustCreateAccount(t, tx.Client(), &service.Account{ + Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, + "fetched_at": fetched.UTC().Format(time.RFC3339Nano), + "last_attempt_at": fetched.UTC().Format(time.RFC3339Nano), + "next_refresh_at": fetched.Add(maxWait).UTC().Format(time.RFC3339Nano), + }, + }, + LastUsedAt: &lastUsed, + }) + } + createFailed := func(name string, lastAttempt, lastUsed, nextRefresh time.Time, nextRefreshRaw string) *service.Account { + t.Helper() + snapshot := map[string]any{ + "status": service.OllamaCloudUsageStatusFailed, + "last_attempt_at": lastAttempt.UTC().Format(time.RFC3339Nano), + "failure_count": 1, + } + if nextRefreshRaw != "" { + snapshot["next_refresh_at"] = nextRefreshRaw + } else { + snapshot["next_refresh_at"] = nextRefresh.UTC().Format(time.RFC3339Nano) + } + return mustCreateAccount(t, tx.Client(), &service.Account{ + Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: snapshot, + }, + LastUsedAt: &lastUsed, + }) + } + + // 21 groups with activity after fetch but debounce not elapsed — previously + // these alone could fill LIMIT 20 every minute and starve true due groups. + notDueIDs := make(map[int64]struct{}, 21) + for i := 0; i < 21; i++ { + // fetched 10m ago, last used 10s ago → due_at = lastUsed+debounce = now+50s (not due) + acc := createOK(fmt.Sprintf("ollama-not-due-debounce-%02d", i), now.Add(-10*time.Minute), now.Add(-10*time.Second)) + notDueIDs[acc.ID] = struct{}{} + } + + // Truly due via max-wait: fetched 2h ago, continuous activity 10s ago. + // due_at = min(now-10s+1m, now-2h+1h) = now-1h → due. + maxWaitDue := createOK("ollama-due-maxwait", now.Add(-2*time.Hour), now.Add(-10*time.Second)) + + // Success debounce elapsed: last used 2m ago with debounce 1m → due. + debounceDue := createOK("ollama-due-debounce", now.Add(-30*time.Minute), now.Add(-2*time.Minute)) + + // Success still within debounce → not due. + _ = createOK("ollama-not-due-fresh", now.Add(-30*time.Minute), now.Add(-20*time.Second)) + + // Failure blocked by next_refresh_at backoff even with new activity. + _ = createFailed("ollama-fail-backoff", now.Add(-30*time.Minute), now.Add(-2*time.Minute), now.Add(10*time.Minute), "") + + // Failure after backoff with new request → due. + failDue := createFailed("ollama-fail-due", now.Add(-30*time.Minute), now.Add(-2*time.Minute), now.Add(-time.Minute), "") + + // Invalid next_refresh_at must fail open (not abort query / not block activity due). + failInvalidNext := createFailed("ollama-fail-invalid-next", now.Add(-30*time.Minute), now.Add(-2*time.Minute), time.Time{}, "not-a-timestamp") + + accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, debounce, maxWait, 20) + require.NoError(t, err) + + ids := accountIDs(accounts) + require.Contains(t, ids, maxWaitDue.ID, "max-wait due group must not be starved by not-yet-due activity groups") + require.Contains(t, ids, debounceDue.ID, "success debounce elapsed must be due in SQL") + require.Contains(t, ids, failDue.ID, "failure after backoff with new activity must be due in SQL") + require.Contains(t, ids, failInvalidNext.ID, "invalid next_refresh_at must fail open to activity due") + require.LessOrEqual(t, len(accounts), 20) + + // Fixtures below match service.ollamaCloudUsageIsAutoRefreshDue semantics; + // none of the not-yet-due groups may appear even when they outnumber the limit. + for _, id := range ids { + _, isNotDue := notDueIDs[id] + require.False(t, isNotDue, "not-yet-due debounce group %d must not be returned by SQL LIMIT layer", id) + } + require.NotContains(t, ids, int64(0)) + + // Explicit not-due names must stay out: fresh success and failure still in backoff. + for _, account := range accounts { + require.NotContains(t, account.Name, "not-due") + require.NotEqual(t, "ollama-fail-backoff", account.Name) + require.NotEqual(t, "ollama-not-due-fresh", account.Name) + } +} diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go index 07b67287ba..14512e885c 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go @@ -190,13 +190,15 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC) + debounce := time.Minute + maxWait := time.Hour var capturedSQL string - mock.ExpectQuery("WITH candidates AS"). - WithArgs(now, 20). - WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock.ExpectQuery("WITH eligible AS"). + WithArgs(now.UTC(), debounce.Seconds(), maxWait.Seconds(), 20, service.OllamaCloudUsageMinFetchInterval.Seconds()). + WillReturnRows(sqlmock.NewRows([]string{"id", "group_last_used_at"})) repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil) - accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, 20) + accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, debounce, maxWait, 20) require.NoError(t, err) require.Empty(t, accounts) @@ -209,10 +211,24 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) { ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"), "jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'", `extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb`, - "parsed_next_refresh_at::timestamptz <= $1", + "MAX(last_used_at) AS group_last_used_at", "PARTITION BY api_key", "WHERE group_rank = 1", - "LIMIT $2", + "LIMIT $4", + "make_interval(secs => $2::double precision)", + "make_interval(secs => $3::double precision)", + // Minimum interval floor between successful fetches. + "make_interval(secs => $5::double precision)", + // jsonpath .datetime() only accepts the ISO-8601 "Z" designator from + // PostgreSQL 17 on, and this service writes UTC timestamps. Without this + // rewrite every parsed_* column is NULL on 14-16 and the due filter + // collapses into its fail-open branch. + `regexp_replace( regexp_replace( fetched_at, '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', '\1\2' ), 'Z$', '+00:00' )`, + "group_last_used_at > parsed_fetched_at::timestamptz", + "group_last_used_at > parsed_last_attempt_at::timestamptz", + "$1 >= activity_due_at", + "COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)", + "ORDER BY due_class, due_at NULLS FIRST, id", } { require.Contains(t, normalized, clause) } diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 8addad4342..2b66b91d76 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -199,6 +199,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldMcpXMLInject, group.FieldSupportedModelScopes, group.FieldAllowMessagesDispatch, + group.FieldAllowLive, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -948,6 +949,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { SupportedModelScopes: g.SupportedModelScopes, SortOrder: g.SortOrder, AllowMessagesDispatch: g.AllowMessagesDispatch, + AllowLive: g.AllowLive, RequireOAuthOnly: g.RequireOauthOnly, RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, diff --git a/backend/internal/repository/batch_image_repo.go b/backend/internal/repository/batch_image_repo.go index 6531f75fd5..7c8991c8d4 100644 --- a/backend/internal/repository/batch_image_repo.go +++ b/backend/internal/repository/batch_image_repo.go @@ -750,7 +750,7 @@ INSERT INTO batch_image_jobs ( batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price, pricing_snapshot_version, currency, hold_id, - idempotency_key, request_hash, manifest_hash, retry_count, output_expires_at + idempotency_key, request_hash, manifest_hash, retry_count, session_id, output_expires_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, @@ -760,7 +760,7 @@ INSERT INTO batch_image_jobs ( $25, $26, $27, $28, $29, $30, $31, - $32, $33, $34, $35, $36 + $32, $33, $34, $35, $36, $37 ) RETURNING `+batchImageJobColumns, params.BatchID, params.UserID, params.APIKeyID, params.AccountID, params.Provider, params.Model, params.TaskName, params.ParentBatchID, params.Status, @@ -771,7 +771,7 @@ RETURNING `+batchImageJobColumns, params.BatchDiscountMultiplier, params.HoldMultiplier, params.BillableUnitPrice, params.HoldUnitPrice, params.PricingSnapshotVersion, params.Currency, params.HoldID, - params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.OutputExpiresAt, + params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.SessionID, params.OutputExpiresAt, )) } @@ -825,7 +825,7 @@ batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price pricing_snapshot_version, currency, hold_id, idempotency_key, request_hash, manifest_hash, -retry_count, version, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at, +retry_count, version, session_id, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at, last_error_code, last_error_message, created_at, updated_at, submitted_at, started_at, finished_at, settled_at` @@ -838,6 +838,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) { var parentBatchID sql.NullString var holdAmount, actualCost sql.NullFloat64 var holdID, idempotencyKey, requestHash, manifestHash sql.NullString + var sessionID sql.NullString var outputExpiresAt, inputDeletedAt, outputDeletedAt, downloadedAt, userDeletedAt sql.NullTime var lastErrorCode, lastErrorMessage sql.NullString var submittedAt, startedAt, finishedAt, settledAt sql.NullTime @@ -852,7 +853,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) { &job.PricingSnapshotVersion, &job.Currency, &holdID, &idempotencyKey, &requestHash, &manifestHash, - &job.RetryCount, &job.Version, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt, + &job.RetryCount, &job.Version, &sessionID, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt, &lastErrorCode, &lastErrorMessage, &job.CreatedAt, &job.UpdatedAt, &submittedAt, &startedAt, &finishedAt, &settledAt, ) @@ -874,6 +875,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) { job.IdempotencyKey = batchImageNullStringPtr(idempotencyKey) job.RequestHash = batchImageNullStringPtr(requestHash) job.ManifestHash = batchImageNullStringPtr(manifestHash) + job.SessionID = batchImageNullStringPtr(sessionID) job.OutputExpiresAt = batchImageNullTimePtr(outputExpiresAt) job.InputDeletedAt = batchImageNullTimePtr(inputDeletedAt) job.OutputDeletedAt = batchImageNullTimePtr(outputDeletedAt) diff --git a/backend/internal/repository/concurrency_cache.go b/backend/internal/repository/concurrency_cache.go index 5341d411e9..37e839f287 100644 --- a/backend/internal/repository/concurrency_cache.go +++ b/backend/internal/repository/concurrency_cache.go @@ -29,11 +29,15 @@ const ( // 格式: concurrency:user:{userID} userSlotKeyPrefix = "concurrency:user:" // 格式: concurrency:api_key:{apiKeyID} - apiKeySlotKeyPrefix = "concurrency:api_key:" + apiKeySlotKeyPrefix = "concurrency:api_key:" + liveAccountSlotKeyPrefix = "concurrency:live:account:" + liveUserSlotKeyPrefix = "concurrency:live:user:" + liveAPIKeySlotKeyPrefix = "concurrency:live:api_key:" // API-key-scoped client WebSocket ingress leases use a shorter TTL than // ordinary request slots, because idle ingress sessions do not hold a turn slot. openAIWSIngressLeaseKeyPrefix = "concurrency:openai_ws_ingress:api_key:" openAIWSIngressLeaseTTLSeconds = 60 + liveLeaseTTLSeconds = 60 // 等待队列计数器格式: concurrency:wait:{userID} waitQueueKeyPrefix = "concurrency:wait:" // 账号级等待队列计数器格式: wait:account:{accountID} @@ -59,7 +63,7 @@ const ( var ( // acquireScript 使用有序集合计数并在未达上限时添加槽位 // 使用 Redis TIME 命令获取服务器时间,避免多实例时钟不同步问题 - // KEYS[1] = 有序集合键 (concurrency:account:{id} / concurrency:user:{id}) + // KEYS[1] = 普通槽位键,KEYS[2] = 对应 Live 槽位键 // ARGV[1] = maxConcurrency // ARGV[2] = TTL(秒) // ARGV[3] = requestID @@ -69,6 +73,7 @@ var ( -- replicates correctly. No-op on Redis 5.0+ (effects replication is default). redis.replicate_commands() local key = KEYS[1] + local liveKey = KEYS[2] local maxConcurrency = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local requestID = ARGV[3] @@ -80,6 +85,7 @@ var ( -- 清理过期槽位 redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore) + redis.call('ZREMRANGEBYSCORE', liveKey, '-inf', now - 60) -- 检查是否已存在(支持重试场景刷新时间戳) local exists = redis.call('ZSCORE', key, requestID) @@ -90,7 +96,7 @@ var ( end -- 检查是否达到并发上限 - local count = redis.call('ZCARD', key) + local count = redis.call('ZCARD', key) + redis.call('ZCARD', liveKey) if count < maxConcurrency then redis.call('ZADD', key, now, requestID) redis.call('EXPIRE', key, ttl) @@ -102,13 +108,14 @@ var ( // getCountScript 统计有序集合中的槽位数量并清理过期条目 // 使用 Redis TIME 命令获取服务器时间 - // KEYS[1] = 有序集合键 + // KEYS[1] = 普通槽位键,KEYS[2] = 对应 Live 槽位键 // ARGV[1] = TTL(秒) getCountScript = redis.NewScript(` -- Redis 3.2-4.x compat: opt into effects replication so redis.call('TIME') -- replicates correctly. No-op on Redis 5.0+ (effects replication is default). redis.replicate_commands() local key = KEYS[1] + local liveKey = KEYS[2] local ttl = tonumber(ARGV[1]) -- 使用 Redis 服务器时间 @@ -117,7 +124,60 @@ var ( local expireBefore = now - ttl redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore) - return redis.call('ZCARD', key) + redis.call('ZREMRANGEBYSCORE', liveKey, '-inf', now - 60) + return redis.call('ZCARD', key) + redis.call('ZCARD', liveKey) + `) + + acquireLiveLeaseScript = redis.NewScript(` + redis.replicate_commands() + local accountRegular = KEYS[1] + local accountLive = KEYS[2] + local userRegular = KEYS[3] + local userLive = KEYS[4] + local apiLive = KEYS[5] + local accountMax = tonumber(ARGV[1]) + local userMax = tonumber(ARGV[2]) + local ttl = tonumber(ARGV[3]) + local leaseID = ARGV[4] + local replacing = tonumber(ARGV[5]) + local now = tonumber(redis.call('TIME')[1]) + local liveExpireBefore = now - ttl + redis.call('ZREMRANGEBYSCORE', accountLive, '-inf', liveExpireBefore) + redis.call('ZREMRANGEBYSCORE', userLive, '-inf', liveExpireBefore) + redis.call('ZREMRANGEBYSCORE', apiLive, '-inf', liveExpireBefore) + if redis.call('ZSCORE', accountLive, leaseID) ~= false then + return 1 + end + local accountCount = redis.call('ZCARD', accountRegular) + redis.call('ZCARD', accountLive) + local userCount = redis.call('ZCARD', userRegular) + redis.call('ZCARD', userLive) + local allowance = 0 + if replacing == 1 then allowance = 1 end + if accountMax > 0 and accountCount >= accountMax + allowance then return 0 end + if userMax > 0 and userCount >= userMax + allowance then return 0 end + redis.call('ZADD', accountLive, now, leaseID) + redis.call('ZADD', userLive, now, leaseID) + redis.call('ZADD', apiLive, now, leaseID) + redis.call('EXPIRE', accountLive, ttl) + redis.call('EXPIRE', userLive, ttl) + redis.call('EXPIRE', apiLive, ttl) + return 1 + `) + + refreshLiveLeaseScript = redis.NewScript(` + redis.replicate_commands() + local ttl = tonumber(ARGV[1]) + local leaseID = ARGV[2] + local now = tonumber(redis.call('TIME')[1]) + local expireBefore = now - ttl + for _, key in ipairs(KEYS) do + redis.call('ZREMRANGEBYSCORE', key, '-inf', expireBefore) + if redis.call('ZSCORE', key, leaseID) == false then return 0 end + end + for _, key in ipairs(KEYS) do + redis.call('ZADD', key, now, leaseID) + redis.call('EXPIRE', key, ttl) + end + return 1 `) // trackSlotScript 记录 stats-only 槽位,不做并发上限判断。 @@ -330,6 +390,18 @@ func apiKeySlotKey(apiKeyID int64) string { return fmt.Sprintf("%s%d", apiKeySlotKeyPrefix, apiKeyID) } +func liveAccountSlotKey(accountID int64) string { + return fmt.Sprintf("%s%d", liveAccountSlotKeyPrefix, accountID) +} + +func liveUserSlotKey(userID int64) string { + return fmt.Sprintf("%s%d", liveUserSlotKeyPrefix, userID) +} + +func liveAPIKeySlotKey(apiKeyID int64) string { + return fmt.Sprintf("%s%d", liveAPIKeySlotKeyPrefix, apiKeyID) +} + func openAIWSIngressLeaseKey(apiKeyID int64) string { return fmt.Sprintf("%s%d", openAIWSIngressLeaseKeyPrefix, apiKeyID) } @@ -559,7 +631,7 @@ func runScriptInt64Pair(ctx context.Context, rdb *redis.Client, script *redis.Sc func (c *concurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) { key := accountSlotKey(accountID) // 时间戳在 Lua 脚本内使用 Redis TIME 命令获取,确保多实例时钟一致 - result, now, err := runScriptInt64Pair(ctx, c.rdb, acquireScript, []string{key}, maxConcurrency, c.slotTTLSeconds, requestID) + result, now, err := runScriptInt64Pair(ctx, c.rdb, acquireScript, []string{key, liveAccountSlotKey(accountID)}, maxConcurrency, c.slotTTLSeconds, requestID) if err != nil { return false, err } @@ -583,7 +655,7 @@ func (c *concurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int func (c *concurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) { key := accountSlotKey(accountID) // 时间戳在 Lua 脚本内使用 Redis TIME 命令获取 - result, err := getCountScript.Run(ctx, c.rdb, []string{key}, c.slotTTLSeconds).Int() + result, err := getCountScript.Run(ctx, c.rdb, []string{key, liveAccountSlotKey(accountID)}, c.slotTTLSeconds).Int() if err != nil { return 0, err } @@ -605,14 +677,18 @@ func (c *concurrencyCache) GetAccountConcurrencyBatch(ctx context.Context, accou type accountCmd struct { accountID int64 zcardCmd *redis.IntCmd + liveCmd *redis.IntCmd } cmds := make([]accountCmd, 0, len(accountIDs)) for _, accountID := range accountIDs { slotKey := accountSlotKeyPrefix + strconv.FormatInt(accountID, 10) + liveKey := liveAccountSlotKeyPrefix + strconv.FormatInt(accountID, 10) pipe.ZRemRangeByScore(ctx, slotKey, "-inf", strconv.FormatInt(cutoffTime, 10)) + pipe.ZRemRangeByScore(ctx, liveKey, "-inf", strconv.FormatInt(now.Unix()-liveLeaseTTLSeconds, 10)) cmds = append(cmds, accountCmd{ accountID: accountID, zcardCmd: pipe.ZCard(ctx, slotKey), + liveCmd: pipe.ZCard(ctx, liveKey), }) } @@ -622,7 +698,7 @@ func (c *concurrencyCache) GetAccountConcurrencyBatch(ctx context.Context, accou result := make(map[int64]int, len(accountIDs)) for _, cmd := range cmds { - result[cmd.accountID] = int(cmd.zcardCmd.Val()) + result[cmd.accountID] = int(cmd.zcardCmd.Val() + cmd.liveCmd.Val()) } return result, nil } @@ -632,7 +708,7 @@ func (c *concurrencyCache) GetAccountConcurrencyBatch(ctx context.Context, accou func (c *concurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) { key := userSlotKey(userID) // 时间戳在 Lua 脚本内使用 Redis TIME 命令获取,确保多实例时钟一致 - result, now, err := runScriptInt64Pair(ctx, c.rdb, acquireScript, []string{key}, maxConcurrency, c.slotTTLSeconds, requestID) + result, now, err := runScriptInt64Pair(ctx, c.rdb, acquireScript, []string{key, liveUserSlotKey(userID)}, maxConcurrency, c.slotTTLSeconds, requestID) if err != nil { return false, err } @@ -656,7 +732,7 @@ func (c *concurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64, re func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) { key := userSlotKey(userID) // 时间戳在 Lua 脚本内使用 Redis TIME 命令获取 - result, err := getCountScript.Run(ctx, c.rdb, []string{key}, c.slotTTLSeconds).Int() + result, err := getCountScript.Run(ctx, c.rdb, []string{key, liveUserSlotKey(userID)}, c.slotTTLSeconds).Int() if err != nil { return 0, err } @@ -716,6 +792,57 @@ func (c *concurrencyCache) ReleaseOpenAIWSIngressLease(ctx context.Context, apiK return c.rdb.ZRem(ctx, openAIWSIngressLeaseKey(apiKeyID), leaseID).Err() } +func (c *concurrencyCache) AcquireLiveLease( + ctx context.Context, + accountID int64, + accountMax int, + userID int64, + userMax int, + apiKeyID int64, + leaseID string, + replacingRegularSlots bool, +) (bool, error) { + if c == nil || c.rdb == nil || accountID <= 0 || userID <= 0 || apiKeyID <= 0 || leaseID == "" { + return false, nil + } + replacing := 0 + if replacingRegularSlots { + replacing = 1 + } + result, err := acquireLiveLeaseScript.Run(ctx, c.rdb, []string{ + accountSlotKey(accountID), + liveAccountSlotKey(accountID), + userSlotKey(userID), + liveUserSlotKey(userID), + liveAPIKeySlotKey(apiKeyID), + }, accountMax, userMax, liveLeaseTTLSeconds, leaseID, replacing).Int() + return result == 1, err +} + +func (c *concurrencyCache) RefreshLiveLease(ctx context.Context, accountID, userID, apiKeyID int64, leaseID string) (bool, error) { + if c == nil || c.rdb == nil || leaseID == "" { + return false, nil + } + result, err := refreshLiveLeaseScript.Run(ctx, c.rdb, []string{ + liveAccountSlotKey(accountID), + liveUserSlotKey(userID), + liveAPIKeySlotKey(apiKeyID), + }, liveLeaseTTLSeconds, leaseID).Int() + return result == 1, err +} + +func (c *concurrencyCache) ReleaseLiveLease(ctx context.Context, accountID, userID, apiKeyID int64, leaseID string) error { + if c == nil || c.rdb == nil || leaseID == "" { + return nil + } + pipe := c.rdb.TxPipeline() + pipe.ZRem(ctx, liveAccountSlotKey(accountID), leaseID) + pipe.ZRem(ctx, liveUserSlotKey(userID), leaseID) + pipe.ZRem(ctx, liveAPIKeySlotKey(apiKeyID), leaseID) + _, err := pipe.Exec(ctx) + return err +} + func (c *concurrencyCache) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) { if len(apiKeyIDs) == 0 { return map[int64]int{}, nil @@ -731,14 +858,18 @@ func (c *concurrencyCache) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKey type apiKeyCmd struct { apiKeyID int64 zcardCmd *redis.IntCmd + liveCmd *redis.IntCmd } cmds := make([]apiKeyCmd, 0, len(apiKeyIDs)) for _, apiKeyID := range apiKeyIDs { slotKey := apiKeySlotKeyPrefix + strconv.FormatInt(apiKeyID, 10) + liveKey := liveAPIKeySlotKeyPrefix + strconv.FormatInt(apiKeyID, 10) pipe.ZRemRangeByScore(ctx, slotKey, "-inf", strconv.FormatInt(cutoffTime, 10)) + pipe.ZRemRangeByScore(ctx, liveKey, "-inf", strconv.FormatInt(now.Unix()-liveLeaseTTLSeconds, 10)) cmds = append(cmds, apiKeyCmd{ apiKeyID: apiKeyID, zcardCmd: pipe.ZCard(ctx, slotKey), + liveCmd: pipe.ZCard(ctx, liveKey), }) } @@ -748,7 +879,7 @@ func (c *concurrencyCache) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKey result := make(map[int64]int, len(apiKeyIDs)) for _, cmd := range cmds { - result[cmd.apiKeyID] = int(cmd.zcardCmd.Val()) + result[cmd.apiKeyID] = int(cmd.zcardCmd.Val() + cmd.liveCmd.Val()) } return result, nil } @@ -834,17 +965,21 @@ func (c *concurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts [] id int64 maxConcurrency int zcardCmd *redis.IntCmd + liveCmd *redis.IntCmd getCmd *redis.StringCmd } cmds := make([]accountCmds, 0, len(accounts)) for _, acc := range accounts { slotKey := accountSlotKeyPrefix + strconv.FormatInt(acc.ID, 10) + liveKey := liveAccountSlotKeyPrefix + strconv.FormatInt(acc.ID, 10) waitKey := accountWaitKeyPrefix + strconv.FormatInt(acc.ID, 10) pipe.ZRemRangeByScore(ctx, slotKey, "-inf", strconv.FormatInt(cutoffTime, 10)) + pipe.ZRemRangeByScore(ctx, liveKey, "-inf", strconv.FormatInt(now.Unix()-liveLeaseTTLSeconds, 10)) ac := accountCmds{ id: acc.ID, maxConcurrency: acc.MaxConcurrency, zcardCmd: pipe.ZCard(ctx, slotKey), + liveCmd: pipe.ZCard(ctx, liveKey), getCmd: pipe.Get(ctx, waitKey), } cmds = append(cmds, ac) @@ -856,7 +991,7 @@ func (c *concurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts [] loadMap := make(map[int64]*service.AccountLoadInfo, len(accounts)) for _, ac := range cmds { - currentConcurrency := int(ac.zcardCmd.Val()) + currentConcurrency := int(ac.zcardCmd.Val() + ac.liveCmd.Val()) waitingCount := 0 if v, err := ac.getCmd.Int(); err == nil { waitingCount = v @@ -894,17 +1029,21 @@ func (c *concurrencyCache) GetUsersLoadBatch(ctx context.Context, users []servic id int64 maxConcurrency int zcardCmd *redis.IntCmd + liveCmd *redis.IntCmd getCmd *redis.StringCmd } cmds := make([]userCmds, 0, len(users)) for _, u := range users { slotKey := userSlotKeyPrefix + strconv.FormatInt(u.ID, 10) + liveKey := liveUserSlotKeyPrefix + strconv.FormatInt(u.ID, 10) waitKey := waitQueueKeyPrefix + strconv.FormatInt(u.ID, 10) pipe.ZRemRangeByScore(ctx, slotKey, "-inf", strconv.FormatInt(cutoffTime, 10)) + pipe.ZRemRangeByScore(ctx, liveKey, "-inf", strconv.FormatInt(now.Unix()-liveLeaseTTLSeconds, 10)) uc := userCmds{ id: u.ID, maxConcurrency: u.MaxConcurrency, zcardCmd: pipe.ZCard(ctx, slotKey), + liveCmd: pipe.ZCard(ctx, liveKey), getCmd: pipe.Get(ctx, waitKey), } cmds = append(cmds, uc) @@ -916,7 +1055,7 @@ func (c *concurrencyCache) GetUsersLoadBatch(ctx context.Context, users []servic loadMap := make(map[int64]*service.UserLoadInfo, len(users)) for _, uc := range cmds { - currentConcurrency := int(uc.zcardCmd.Val()) + currentConcurrency := int(uc.zcardCmd.Val() + uc.liveCmd.Val()) waitingCount := 0 if v, err := uc.getCmd.Int(); err == nil { waitingCount = v diff --git a/backend/internal/repository/concurrency_cache_integration_test.go b/backend/internal/repository/concurrency_cache_integration_test.go index 02821159ee..4ec795c298 100644 --- a/backend/internal/repository/concurrency_cache_integration_test.go +++ b/backend/internal/repository/concurrency_cache_integration_test.go @@ -97,6 +97,42 @@ func (s *ConcurrencyCacheSuite) TestOpenAIWSIngressAPIKeySlot_ReapsCrashedLeaseW require.Equal(s.T(), int64(2), count) } +func (s *ConcurrencyCacheSuite) TestLiveLease_CountsTowardRegularAccountAndUserLimits() { + liveCache, ok := s.cache.(service.LiveConcurrencyCache) + require.True(s.T(), ok) + + accountID := int64(9101) + userID := int64(9102) + apiKeyID := int64(9103) + acquired, err := liveCache.AcquireLiveLease( + s.ctx, + accountID, + 1, + userID, + 1, + apiKeyID, + "live-integration", + false, + ) + require.NoError(s.T(), err) + require.True(s.T(), acquired) + + regularAccount, err := s.cache.AcquireAccountSlot(s.ctx, accountID, 1, "regular-account") + require.NoError(s.T(), err) + require.False(s.T(), regularAccount) + regularUser, err := s.cache.AcquireUserSlot(s.ctx, userID, 1, "regular-user") + require.NoError(s.T(), err) + require.False(s.T(), regularUser) + + refreshed, err := liveCache.RefreshLiveLease(s.ctx, accountID, userID, apiKeyID, "live-integration") + require.NoError(s.T(), err) + require.True(s.T(), refreshed) + require.NoError(s.T(), liveCache.ReleaseLiveLease(s.ctx, accountID, userID, apiKeyID, "live-integration")) + regularAccount, err = s.cache.AcquireAccountSlot(s.ctx, accountID, 1, "regular-account") + require.NoError(s.T(), err) + require.True(s.T(), regularAccount) +} + func (s *ConcurrencyCacheSuite) TestAccountSlot_AcquireAndRelease() { accountID := int64(10) reqID1, reqID2, reqID3 := "req1", "req2", "req3" diff --git a/backend/internal/repository/concurrency_cache_live_test.go b/backend/internal/repository/concurrency_cache_live_test.go new file mode 100644 index 0000000000..6f8a143165 --- /dev/null +++ b/backend/internal/repository/concurrency_cache_live_test.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestLiveLeaseReplacesRegularSlotsAndCountsTowardLimits(t *testing.T) { + redisServer := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + regular := NewConcurrencyCache(client, 15, 900) + live, ok := regular.(service.LiveConcurrencyCache) + require.True(t, ok) + ctx := context.Background() + + accountAcquired, err := regular.AcquireAccountSlot(ctx, 10, 1, "regular-account") + require.NoError(t, err) + require.True(t, accountAcquired) + userAcquired, err := regular.AcquireUserSlot(ctx, 20, 1, "regular-user") + require.NoError(t, err) + require.True(t, userAcquired) + + acquired, err := live.AcquireLiveLease(ctx, 10, 1, 20, 1, 30, "live-lease", true) + require.NoError(t, err) + require.True(t, acquired) + require.NoError(t, regular.ReleaseAccountSlot(ctx, 10, "regular-account")) + require.NoError(t, regular.ReleaseUserSlot(ctx, 20, "regular-user")) + + accountCount, err := regular.GetAccountConcurrency(ctx, 10) + require.NoError(t, err) + require.Equal(t, 1, accountCount) + userCount, err := regular.GetUserConcurrency(ctx, 20) + require.NoError(t, err) + require.Equal(t, 1, userCount) + accountAcquired, err = regular.AcquireAccountSlot(ctx, 10, 1, "ordinary-blocked") + require.NoError(t, err) + require.False(t, accountAcquired) + + refreshed, err := live.RefreshLiveLease(ctx, 10, 20, 30, "live-lease") + require.NoError(t, err) + require.True(t, refreshed) + require.NoError(t, live.ReleaseLiveLease(ctx, 10, 20, 30, "live-lease")) + accountAcquired, err = regular.AcquireAccountSlot(ctx, 10, 1, "ordinary-allowed") + require.NoError(t, err) + require.True(t, accountAcquired) +} + +func TestLiveLeaseExpiresWithoutRefresh(t *testing.T) { + redisServer := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + regular := NewConcurrencyCache(client, 15, 900) + live, ok := regular.(service.LiveConcurrencyCache) + require.True(t, ok) + ctx := context.Background() + + acquired, err := live.AcquireLiveLease(ctx, 10, 1, 20, 1, 30, "expired-live", false) + require.NoError(t, err) + require.True(t, acquired) + + redisServer.FastForward(61 * time.Second) + acquired, err = regular.AcquireAccountSlot(ctx, 10, 1, "ordinary-after-expiry") + require.NoError(t, err) + require.True(t, acquired) + refreshed, err := live.RefreshLiveLease(ctx, 10, 20, 30, "expired-live") + require.NoError(t, err) + require.False(t, refreshed) +} diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index b8229eed1a..d19c3cd512 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -2,7 +2,10 @@ package repository import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "strconv" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -10,6 +13,7 @@ import ( ) const stickySessionPrefix = "sticky_session:" +const liveCallPrefix = "live:call:" type gatewayCache struct { rdb *redis.Client @@ -54,6 +58,7 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64 // Compile-time assertion: gatewayCache must implement CyberSessionBlockStore. var _ service.CyberSessionBlockStore = (*gatewayCache)(nil) +var _ service.LiveCallStore = (*gatewayCache)(nil) const cyberSessionBlockPrefix = "cyber_session_block:" @@ -71,3 +76,142 @@ func (c *gatewayCache) IsCyberSessionBlocked(ctx context.Context, key string) (b } return n > 0, nil } + +var claimLiveControllerScript = redis.NewScript(` + local key = KEYS[1] + local target = ARGV[1] + local owner = ARGV[2] + local current = redis.call('HGET', key, 'controller') + if current == false or current == 'closed' then + return 0 + end + if target == 'observer' and current ~= 'pending' then + return 0 + end + if target == 'proxy' and current ~= 'pending' and current ~= 'observer' and + (current ~= 'proxy' or redis.call('HGET', key, 'controller_owner') ~= owner) then + return 0 + end + redis.call('HSET', key, 'controller', target, 'controller_owner', owner) + return 1 +`) + +var markLiveCallClosedScript = redis.NewScript(` + local key = KEYS[1] + if redis.call('EXISTS', key) == 0 then + return 0 + end + if redis.call('HGET', key, 'controller') == 'closed' then + return 0 + end + redis.call('HSET', key, 'controller', 'closed', 'controller_owner', '') + redis.call('EXPIRE', key, ARGV[1]) + return 1 +`) + +var releaseLiveControllerScript = redis.NewScript(` + local key = KEYS[1] + if redis.call('HGET', key, 'controller') ~= 'proxy' or + redis.call('HGET', key, 'controller_owner') ~= ARGV[1] then + return 0 + end + redis.call('HSET', key, 'controller', 'pending', 'controller_owner', '') + return 1 +`) + +func liveCallKey(callHash string) string { + return liveCallPrefix + callHash +} + +func HashLiveCallID(callID string) string { + sum := sha256.Sum256([]byte(callID)) + return hex.EncodeToString(sum[:]) +} + +func (c *gatewayCache) SaveLiveCall(ctx context.Context, record *service.LiveCallRecord, ttl time.Duration) error { + if record == nil || record.CallHash == "" || record.CallID == "" { + return fmt.Errorf("invalid live call record") + } + values := map[string]any{ + "call_id": record.CallID, + "account_id": record.AccountID, + "api_key_id": record.APIKeyID, + "user_id": record.UserID, + "group_id": record.GroupID, + "subscription_id": record.SubscriptionID, + "lease_id": record.LeaseID, + "model": record.Model, + "created_at": record.CreatedAt.UnixMilli(), + "expires_at": record.ExpiresAt.UnixMilli(), + "controller": record.Controller, + "controller_owner": record.ControllerOwner, + "user_agent": record.UserAgent, + "ip_address": record.IPAddress, + "inbound_endpoint": record.InboundEndpoint, + "attestation": record.AttestationCiphertext, + } + key := liveCallKey(record.CallHash) + pipe := c.rdb.TxPipeline() + pipe.HSet(ctx, key, values) + pipe.Expire(ctx, key, ttl) + _, err := pipe.Exec(ctx) + return err +} + +func (c *gatewayCache) GetLiveCall(ctx context.Context, callHash string) (*service.LiveCallRecord, error) { + values, err := c.rdb.HGetAll(ctx, liveCallKey(callHash)).Result() + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, service.ErrLiveCallNotFound + } + parseInt := func(field string) int64 { + value, _ := strconv.ParseInt(values[field], 10, 64) + return value + } + createdAt := time.UnixMilli(parseInt("created_at")) + expiresAt := time.UnixMilli(parseInt("expires_at")) + return &service.LiveCallRecord{ + CallID: values["call_id"], + CallHash: callHash, + AccountID: parseInt("account_id"), + APIKeyID: parseInt("api_key_id"), + UserID: parseInt("user_id"), + GroupID: parseInt("group_id"), + SubscriptionID: parseInt("subscription_id"), + LeaseID: values["lease_id"], + Model: values["model"], + CreatedAt: createdAt, + ExpiresAt: expiresAt, + Controller: values["controller"], + ControllerOwner: values["controller_owner"], + UserAgent: values["user_agent"], + IPAddress: values["ip_address"], + InboundEndpoint: values["inbound_endpoint"], + AttestationCiphertext: values["attestation"], + }, nil +} + +func (c *gatewayCache) ClaimLiveController(ctx context.Context, callHash, controller, owner string) (bool, error) { + result, err := claimLiveControllerScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, controller, owner).Int() + return result == 1, err +} + +func (c *gatewayCache) GetLiveController(ctx context.Context, callHash string) (string, error) { + value, err := c.rdb.HGet(ctx, liveCallKey(callHash), "controller").Result() + if err == redis.Nil { + return "", service.ErrLiveCallNotFound + } + return value, err +} + +func (c *gatewayCache) ReleaseLiveController(ctx context.Context, callHash, owner string) (bool, error) { + result, err := releaseLiveControllerScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, owner).Int() + return result == 1, err +} + +func (c *gatewayCache) MarkLiveCallClosed(ctx context.Context, callHash string, ttl time.Duration) (bool, error) { + result, err := markLiveCallClosedScript.Run(ctx, c.rdb, []string{liveCallKey(callHash)}, int64(ttl.Seconds())).Int() + return result == 1, err +} diff --git a/backend/internal/repository/gateway_cache_live_test.go b/backend/internal/repository/gateway_cache_live_test.go new file mode 100644 index 0000000000..aa4327501f --- /dev/null +++ b/backend/internal/repository/gateway_cache_live_test.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestGatewayCacheLiveCallIdentityAndController(t *testing.T) { + redisServer := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + cache, ok := NewGatewayCache(client).(service.LiveCallStore) + require.True(t, ok) + otherInstance, ok := NewGatewayCache(client).(service.LiveCallStore) + require.True(t, ok) + record := &service.LiveCallRecord{ + CallID: "call_secret", + CallHash: HashLiveCallID("call_secret"), + AccountID: 11, + APIKeyID: 22, + UserID: 33, + GroupID: 44, + LeaseID: "lease", + Model: "gpt-live-test", + AttestationCiphertext: "encrypted-attestation", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + Controller: service.LiveControllerPending, + } + require.NoError(t, cache.SaveLiveCall(context.Background(), record, time.Hour)) + + loaded, err := otherInstance.GetLiveCall(context.Background(), record.CallHash) + require.NoError(t, err) + require.Equal(t, record.CallID, loaded.CallID) + require.Equal(t, record.AccountID, loaded.AccountID) + require.Equal(t, record.AttestationCiphertext, loaded.AttestationCiphertext) + + claimed, err := cache.ClaimLiveController(context.Background(), record.CallHash, service.LiveControllerObserver, "observer-1") + require.NoError(t, err) + require.True(t, claimed) + claimed, err = cache.ClaimLiveController(context.Background(), record.CallHash, service.LiveControllerProxy, "proxy-1") + require.NoError(t, err) + require.True(t, claimed) + controller, err := cache.GetLiveController(context.Background(), record.CallHash) + require.NoError(t, err) + require.Equal(t, service.LiveControllerProxy, controller) + + released, err := cache.ReleaseLiveController(context.Background(), record.CallHash, "proxy-1") + require.NoError(t, err) + require.True(t, released) + closed, err := cache.MarkLiveCallClosed(context.Background(), record.CallHash, time.Hour) + require.NoError(t, err) + require.True(t, closed) + closed, err = cache.MarkLiveCallClosed(context.Background(), record.CallHash, time.Hour) + require.NoError(t, err) + require.False(t, closed) +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 96be19a70c..50c9aafa5f 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -90,6 +90,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). + SetAllowLive(groupIn.AllowLive). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -255,6 +256,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). SetMcpXMLInject(groupIn.MCPXMLInject). SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). + SetAllowLive(groupIn.AllowLive). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). diff --git a/backend/internal/repository/integration_harness_test.go b/backend/internal/repository/integration_harness_test.go index 5857fbcb2a..46f18598f2 100644 --- a/backend/internal/repository/integration_harness_test.go +++ b/backend/internal/repository/integration_harness_test.go @@ -140,7 +140,20 @@ func dockerIsAvailable(ctx context.Context) bool { return cmd.Run() == nil } +// selectDockerImage resolves the container image for the harness. +// +// SUB2API_TEST_POSTGRES_IMAGE overrides the PostgreSQL image so the suite can be +// run against the oldest documented-supported server, not just the newest. That +// matters for SQL that behaves differently across major versions: jsonpath +// .datetime() only accepts the ISO-8601 "Z" designator from PostgreSQL 17 on, so +// a suite pinned to 18 cannot observe breakage on 14-16. +// +// SUB2API_TEST_POSTGRES_IMAGE=postgres:15-alpine go test -tags integration ./internal/repository/ func selectDockerImage(ctx context.Context, preferred string) string { + if override := strings.TrimSpace(os.Getenv("SUB2API_TEST_POSTGRES_IMAGE")); override != "" && + strings.HasPrefix(preferred, "postgres:") { + return override + } if dockerImageExists(ctx, preferred) { return preferred } diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 68eb790edb..b393bc15b6 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -55,6 +55,9 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true) requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due") + // groups: OpenAI Live 默认关闭,管理员显式开启后才可访问。 + requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) + // api_keys: key length should be 128 requireColumn(t, tx, "api_keys", "key", "character varying", 128, false) diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 54d24b5957..809b0652b5 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -79,6 +79,7 @@ var usageLogInsertArgTypes = [...]string{ "text", // billing_tier "text", // billing_mode "numeric", // account_stats_cost + "text", // session_id "timestamptz", // created_at } @@ -274,6 +275,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, @@ -281,7 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -728,10 +730,13 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) AS (VALUES `) - args := make([]any, 0, len(keys)*56) + // Each batch row prepends the synthetic input_index before the 57 + // usage-log column values. + args := make([]any, 0, len(keys)*58) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -815,6 +820,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) SELECT @@ -873,6 +879,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + session_id, created_at FROM input ON CONFLICT (request_id, api_key_id) DO NOTHING @@ -971,10 +978,11 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*56) + args := make([]any, 0, len(preparedList)*57) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1055,6 +1063,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) SELECT @@ -1113,6 +1122,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + session_id, created_at FROM input ON CONFLICT (request_id, api_key_id) DO NOTHING @@ -1179,6 +1189,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared billing_tier, billing_mode, account_stats_cost, + session_id, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, @@ -1186,7 +1197,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1227,6 +1238,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { modelMappingChain := nullString(log.ModelMappingChain) billingTier := nullString(log.BillingTier) billingMode := nullString(log.BillingMode) + sessionID := nullString(log.SessionID) requestedModel := strings.TrimSpace(log.RequestedModel) if requestedModel == "" { requestedModel = strings.TrimSpace(log.Model) @@ -1299,6 +1311,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { billingTier, billingMode, log.AccountStatsCost, // account_stats_cost + sessionID, // session_id createdAt, }, } diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index fb0e03ed60..88b7eeaf35 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -481,6 +481,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e billingTier sql.NullString billingMode sql.NullString accountStatsCost sql.NullFloat64 + sessionID sql.NullString createdAt time.Time ) @@ -541,6 +542,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &billingTier, &billingMode, &accountStatsCost, + &sessionID, &createdAt, ); err != nil { return nil, err @@ -661,6 +663,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if accountStatsCost.Valid { log.AccountStatsCost = &accountStatsCost.Float64 } + if sessionID.Valid { + log.SessionID = &sessionID.String + } return log, nil } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index f039029608..aff472b0cb 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -96,6 +96,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // session_id createdAt, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(99), createdAt)) @@ -185,6 +186,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // session_id createdAt, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(100), createdAt)) @@ -826,6 +828,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullFloat64{}, + sql.NullString{}, now, }}) require.NoError(t, err) @@ -900,6 +903,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // session_id now, }}) require.NoError(t, err) @@ -957,6 +961,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // session_id now, }}) require.NoError(t, err) @@ -1014,6 +1019,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // session_id now, }}) require.NoError(t, err) diff --git a/backend/internal/repository/usage_log_session_id_integration_test.go b/backend/internal/repository/usage_log_session_id_integration_test.go new file mode 100644 index 0000000000..76778b4162 --- /dev/null +++ b/backend/internal/repository/usage_log_session_id_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// TestUsageLog_SessionIDPersistence proves session_id round-trips from insert to +// read and is omitted (NULL) when absent. +func TestUsageLog_SessionIDPersistence(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := newUsageLogRepositoryWithSQL(client, integrationDB) + + user := mustCreateUser(t, client, &service.User{Email: "session-id-" + uuid.NewString() + "@example.com"}) + apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-session-" + uuid.NewString(), Name: "k"}) + account := mustCreateAccount(t, client, &service.Account{Name: "acc-session-" + uuid.NewString()}) + + sessionID := "sess-" + uuid.NewString() + + withSession := &service.UsageLog{ + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: uuid.NewString(), + Model: "claude-3", + InputTokens: 10, + OutputTokens: 5, + TotalCost: 1.0, + ActualCost: 1.0, + SessionID: &sessionID, + CreatedAt: time.Now().UTC(), + } + _, err := repo.Create(ctx, withSession) + require.NoError(t, err) + require.NotZero(t, withSession.ID) + + withoutSession := &service.UsageLog{ + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: uuid.NewString(), + Model: "claude-3", + InputTokens: 7, + OutputTokens: 3, + TotalCost: 0.5, + ActualCost: 0.5, + CreatedAt: time.Now().UTC(), + } + _, err = repo.Create(ctx, withoutSession) + require.NoError(t, err) + + // Round-trip: session id survives insert → read. + got, err := repo.GetByID(ctx, withSession.ID) + require.NoError(t, err) + require.NotNil(t, got.SessionID) + require.Equal(t, sessionID, *got.SessionID) + + // Omission: absent session id reads back as nil (NULL), not empty string. + gotNone, err := repo.GetByID(ctx, withoutSession.ID) + require.NoError(t, err) + require.Nil(t, gotNone.SessionID) +} diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go new file mode 100644 index 0000000000..7804dce306 --- /dev/null +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -0,0 +1,91 @@ +//go:build unit + +package repository + +import ( + "database/sql" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func newSessionIDUsageLog(sessionID *string) *service.UsageLog { + return &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "req-session-id", + Model: "claude-3", + InputTokens: 10, + OutputTokens: 5, + TotalCost: 1.0, + ActualCost: 1.0, + SessionID: sessionID, + CreatedAt: time.Now().UTC(), + } +} + +// TestPrepareUsageLogInsert_SessionIDArgWiring pins the session_id column to the +// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id +// is the penultimate arg (created_at is always last). +func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) { + require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id") + + sessionID := "sess-persisted-123" + prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID)) + + require.Len(t, prepared.args, len(usageLogInsertArgTypes), + "prepared args must match the arg-type table length") + + // created_at is last; session_id is the arg immediately before it. + sessionArg := prepared.args[len(prepared.args)-2] + ns, ok := sessionArg.(sql.NullString) + require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg) + require.True(t, ns.Valid) + require.Equal(t, sessionID, ns.String) + + require.Equal(t, "text", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-2], + "session_id arg type must be text") +} + +// TestPrepareUsageLogInsert_SessionIDNullWhenAbsent proves an absent session id is +// persisted as SQL NULL rather than an empty string. +func TestPrepareUsageLogInsert_SessionIDNullWhenAbsent(t *testing.T) { + prepared := prepareUsageLogInsert(newSessionIDUsageLog(nil)) + sessionArg := prepared.args[len(prepared.args)-2] + ns, ok := sessionArg.(sql.NullString) + require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg) + require.False(t, ns.Valid, "absent session id must be NULL, not empty string") + + empty := "" + preparedEmpty := prepareUsageLogInsert(newSessionIDUsageLog(&empty)) + nsEmpty := preparedEmpty.args[len(preparedEmpty.args)-2].(sql.NullString) + require.False(t, nsEmpty.Valid, "empty session id must also be NULL") +} + +// TestUsageLogInsertQueries_IncludeSessionID guards that every generated INSERT path +// and the SELECT column list reference session_id. +func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) { + require.Contains(t, usageLogSelectColumns, "session_id", + "SELECT column list must include session_id") + + sessionID := "sess-in-query" + log := newSessionIDUsageLog(&sessionID) + prepared := prepareUsageLogInsert(log) + key := usageLogBatchKey(log.RequestID, log.APIKeyID) + + batchQuery, batchArgs := buildUsageLogBatchInsertQuery([]string{key}, + map[string]usageLogInsertPrepared{key: prepared}) + require.Contains(t, batchQuery, "session_id") + // Two column references (INSERT column list + SELECT ... FROM input) plus the CTE def. + require.GreaterOrEqual(t, strings.Count(batchQuery, "session_id"), 3) + require.Len(t, batchArgs, len(prepared.args)+1, + "batch args include the synthetic input_index before usage-log values") + + bestEffortQuery, bestEffortArgs := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared}) + require.Contains(t, bestEffortQuery, "session_id") + require.Len(t, bestEffortArgs, len(prepared.args)) +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index e08ce2f3a6..092573f9d7 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -43,6 +43,16 @@ func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepos } func (r *userRepository) Create(ctx context.Context, userIn *service.User) error { + return r.create(ctx, userIn, false) +} + +// CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份, +// 供注册路径使用。 +func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error { + return r.create(ctx, userIn, true) +} + +func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool) error { if userIn == nil { return nil } @@ -69,11 +79,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error } } + lockKeys := []string{normalizedEmailUniquenessLockKey(userIn.Email)} + if guardEmailAlias { + // 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。 + lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email)) + } releaseEmailLock, err := lockRepositoryScopedKeys( txCtx, txClient, txAwareSQLExecutor(txCtx, r.sql, r.client), - normalizedEmailUniquenessLockKey(userIn.Email), + lockKeys..., ) if err != nil { return err @@ -84,6 +99,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error return err } + if guardEmailAlias { + aliasExists, err := existsByEmailAliasWithClient(txCtx, txClient, userIn.Email) + if err != nil { + return err + } + if aliasExists { + return service.ErrEmailExists + } + } + created, err := txClient.User.Create(). SetEmail(userIn.Email). SetUsername(userIn.Username). @@ -904,6 +929,88 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } +// emailAliasCandidateLimit 限制一次别名查重最多取回的候选行数。探针都以去点后的 +// 本地部分为前缀锚定(见 dotStrippedEmailExpr),正常收件箱的变体只有个位数; +// 上限只是兜底,避免公开未鉴权的注册/发码端点把大表整张读进内存。 +const emailAliasCandidateLimit = 50 + +// ExistsByEmailAlias 见 service.UserRepository。软删除过滤沿用 ExistsByEmail 的默认行为。 +func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + return existsByEmailAliasWithClient(ctx, clientFromContext(ctx, r.client), email) +} + +func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) { + if client == nil { + return false, nil + } + probes := service.EmailAliasDedupProbes(email) + if len(probes) == 0 { + return false, nil + } + + preds := make([]predicate.User, 0, 2*len(probes)) + for _, probe := range probes { + preds = append(preds, + dotStrippedEmailEQ(probe.Local+"@"+probe.Domain), + // "+后缀"的内容未知,只能按前缀匹配。 + dotStrippedEmailLike(escapeLikeWildcards(probe.Local)+"+%@"+escapeLikeWildcards(probe.Domain)), + ) + } + candidates, err := client.User.Query(). + Where(dbuser.Or(preds...)). + Limit(emailAliasCandidateLimit). + Select(dbuser.FieldEmail). + Strings(ctx) + if err != nil { + return false, err + } + + // 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。 + identity := service.NormalizeEmailForAliasDedup(email) + for _, candidate := range candidates { + if service.NormalizeEmailForAliasDedup(candidate) == identity { + return true, nil + } + } + return false, nil +} + +// dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与 +// userEmailLookupPredicate 的精确匹配口径一致,历史数据存在带空白的行)以及全部点号。 +// +// REPLACE(LOWER(TRIM(email)), '.', '') +// +// 两侧都去点,因此一个域名探针即可同时覆盖 Gmail 点号变体与 FQDN 根点(user@gmail.com.)。 +// migrations/190 为同一表达式建了索引。 +func dotStrippedEmailExpr(b *entsql.Builder, s *entsql.Selector) *entsql.Builder { + return b.WriteString("REPLACE(LOWER(TRIM("). + Ident(s.C(dbuser.FieldEmail)). + WriteString(")), '.', '')") +} + +func dotStrippedEmailEQ(value string) predicate.User { + return predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + dotStrippedEmailExpr(b, s).WriteString(" = ").Arg(value) + })) + }) +} + +func dotStrippedEmailLike(pattern string) predicate.User { + return predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + dotStrippedEmailExpr(b, s).WriteString(" LIKE ").Arg(pattern).WriteString(` ESCAPE '\'`) + })) + }) +} + +// escapeLikeWildcards 转义 LIKE 元字符:本地部分合法可含 % 与 _,不转义会扩大匹配面。 +var likeWildcardEscaper = strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`) + +func escapeLikeWildcards(value string) string { + return likeWildcardEscaper.Replace(value) +} + func ensureNormalizedEmailAvailableWithClient(ctx context.Context, client *dbent.Client, userID int64, email string) error { client = clientFromContext(ctx, client) if client == nil { @@ -951,6 +1058,16 @@ func normalizedEmailUniquenessLockKey(email string) string { return "users:normalized-email:" + normalized } +// emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同 +// 别名变体在注册时互斥。 +func emailAliasUniquenessLockKey(email string) string { + identity := service.NormalizeEmailForAliasDedup(email) + if identity == "" { + return "" + } + return "users:email-alias-identity:" + identity +} + func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { client := clientFromContext(ctx, r.client) err := client.UserAllowedGroup.Create(). diff --git a/backend/internal/repository/user_repo_email_alias_test.go b/backend/internal/repository/user_repo_email_alias_test.go new file mode 100644 index 0000000000..6faf7340d2 --- /dev/null +++ b/backend/internal/repository/user_repo_email_alias_test.go @@ -0,0 +1,100 @@ +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func seedUserForAliasTest(t *testing.T, repo *userRepository, email string) { + t.Helper() + require.NoError(t, repo.Create(context.Background(), &service.User{ + Email: email, + Username: email, + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) +} + +func TestUserRepositoryExistsByEmailAlias(t *testing.T) { + cases := []struct { + name string + stored string + probe string + want bool + }{ + {"same address", "someone@gmail.com", "someone@gmail.com", true}, + {"gmail plus alias", "someone@gmail.com", "someone+bulk294@gmail.com", true}, + {"gmail dot trick", "d.axis.2026@gmail.com", "daxis2026@gmail.com", true}, + {"gmail dot trick both sides", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com", true}, + {"stored plus alias found by canonical form", "someone+tag@gmail.com", "someone@gmail.com", true}, + {"googlemail is a gmail alias", "someone@googlemail.com", "some.one@gmail.com", true}, + {"fqdn root dot on probe", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com.", true}, + {"fqdn root dot on stored row", "d.axis.2026@gmail.com.", "daxis2026@gmail.com", true}, + {"legacy row with spacing and case", " D.Axis.2026@Gmail.com ", "daxis2026@gmail.com", true}, + {"non-gmail plus alias", "first.last@qq.com", "first.last+tag@qq.com", true}, + {"different gmail inbox", "someone@gmail.com", "someoneelse@gmail.com", false}, + {"non-gmail dots are significant", "first.last@qq.com", "firstlast@qq.com", false}, + {"different domain", "someone@gmail.com", "someone@qq.com", false}, + {"distinct plus-prefixed locals", "+alice@gmail.com", "+bob@gmail.com", false}, + {"underscore is not a wildcard", "user_x@qq.com", "userax@qq.com", false}, + {"percent is not a wildcard", "a%b@qq.com", "axxb@qq.com", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo, _ := newUserEntRepo(t) + seedUserForAliasTest(t, repo, tc.stored) + + got, err := repo.ExistsByEmailAlias(context.Background(), tc.probe) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestUserRepositoryExistsByEmailAliasIgnoresMalformedInput(t *testing.T) { + repo, _ := newUserEntRepo(t) + seedUserForAliasTest(t, repo, "someone@gmail.com") + + got, err := repo.ExistsByEmailAlias(context.Background(), "not-an-email") + require.NoError(t, err) + require.False(t, got) +} + +func TestUserRepositoryCreateWithEmailAliasGuard(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + seedUserForAliasTest(t, repo, "d.axis.2026@gmail.com") + + // 注册路径:别名变体在唯一性锁内被拒绝。 + err := repo.CreateWithEmailAliasGuard(ctx, &service.User{ + Email: "da.xis.2026+free@googlemail.com", + Username: "alias-variant", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + }) + require.ErrorIs(t, err, service.ErrEmailExists) + + // 不同收件箱仍可注册。 + require.NoError(t, repo.CreateWithEmailAliasGuard(ctx, &service.User{ + Email: "other.person@gmail.com", + Username: "other-person", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) + + // 管理员建号(Create)不受别名限制。 + require.NoError(t, repo.Create(ctx, &service.User{ + Email: "daxis2026+support@gmail.com", + Username: "admin-created", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 11b8eb5f76..06c738cd3c 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -377,6 +377,7 @@ func TestAPIContracts(t *testing.T) { "video_rate_multiplier": 0, "claude_code_only": false, "allow_messages_dispatch": false, + "allow_live": false, "fallback_group_id": null, "fallback_group_id_on_invalid_request": null, "require_oauth_only": false, @@ -1494,6 +1495,10 @@ func (r *stubUserRepo) Create(ctx context.Context, user *service.User) error { return errors.New("not implemented") } +func (r *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + return errors.New("not implemented") +} + func (r *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { user, ok := r.users[id] if !ok { @@ -1573,6 +1578,10 @@ func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e return false, errors.New("not implemented") } +func (r *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + return false, errors.New("not implemented") +} + func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 3c844c6896..f13b094df1 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -131,6 +131,10 @@ func (s *stubUserRepo) Create(ctx context.Context, user *service.User) error { panic("unexpected Create call") } +func (s *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + panic("unexpected CreateWithEmailAliasGuard call") +} + func (s *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { if s.getByID == nil { panic("GetByID not stubbed") @@ -208,6 +212,10 @@ func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e panic("unexpected ExistsByEmail call") } +func (s *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + panic("unexpected ExistsByEmailAlias call") +} + func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ddb78a4160..a2117fe312 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -317,6 +317,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.GET("/all", h.Admin.Group.GetAll) groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary) groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary) + groups.GET("/live-capability", h.Admin.Group.GetLiveCapability) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates) groups.GET("/:id/composite-routes", h.Admin.Group.ListCompositeRoutes) diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index ee435eca8c..b059646fad 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -180,6 +180,8 @@ func RegisterGatewayRoutes( // Codex manifest format; other clients keep the OpenAI-style list. gateway.GET("/models", modelsHandler) gateway.GET("/usage", h.Gateway.Usage) + gateway.POST("/live", h.OpenAIGateway.Live) + gateway.GET("/live/:call_id", h.OpenAIGateway.LiveSideband) // OpenAI Responses API: auto-route based on group platform gateway.POST("/responses", func(c *gin.Context) { if isOpenAIResponsesCompatibleGatewayPlatform(c) { @@ -277,6 +279,8 @@ func RegisterGatewayRoutes( codexDirect := r.Group("/backend-api/codex") codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic) { + codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live) + codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband) codexDirect.POST("/responses", responsesHandler) codexDirect.POST("/responses/*subpath", responsesHandler) codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index ba38910b4b..1620a18318 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -34,6 +34,8 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) { "/chat/completions": {"gateway_handler_chat_completions.go", "openai_chat_completions.go"}, "/embeddings": {"openai_embeddings.go"}, "/alpha/search": {"openai_alpha_search.go"}, + "/live": {"openai_live.go"}, + "/realtime/calls": {"openai_live.go"}, "/images/generations": {"openai_images.go", "grok_media.go"}, "/images/edits": {"openai_images.go", "grok_media.go"}, "/images/generations/async": {"image_task_handler.go"}, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 5b3707d2a0..b7f6d2266a 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -89,6 +89,7 @@ const ( OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions" OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings" OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search" + OpenAIEndpointCapabilityLive OpenAIEndpointCapability = "live" // OpenAIEndpointCapabilityGrokMediaGeneration keeps image/video generation // away from Grok accounts that are explicitly disabled or whose billing // entitlement probe was forbidden. Video status lookups intentionally do not @@ -1435,6 +1436,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } switch capability { case OpenAIEndpointCapabilityChatCompletions: + case OpenAIEndpointCapabilityLive: + return a.Platform == PlatformOpenAI && + a.Type == AccountTypeOAuth && + !a.IsOpenAIPersonalAccessToken() && + !a.IsOpenAIAgentIdentity() case OpenAIEndpointCapabilityResponses: // Responses 支持状态由 accounts.extra 的自动探测标记决定,而非 // credentials 能力集。已探测确认不支持 /v1/responses 的 APIKey 上游 diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index fdba370ea0..b773c43ed3 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -470,6 +470,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn MCPXMLInject: mcpXMLInject, SupportedModelScopes: input.SupportedModelScopes, AllowMessagesDispatch: input.AllowMessagesDispatch, + AllowLive: input.AllowLive, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -480,6 +481,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) + if group.Platform != PlatformOpenAI { + group.AllowLive = false + } sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err @@ -777,6 +781,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.AllowMessagesDispatch != nil { group.AllowMessagesDispatch = *input.AllowMessagesDispatch } + if input.AllowLive != nil { + group.AllowLive = *input.AllowLive + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } @@ -810,6 +817,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) + if group.Platform != PlatformOpenAI { + group.AllowLive = false + } sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Update(ctx, group); err != nil { diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 6c57df1cf0..fcfe39326d 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -120,6 +120,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { SupportedModelScopes: append([]string(nil), source.SupportedModelScopes...), SortOrder: source.SortOrder, AllowMessagesDispatch: source.AllowMessagesDispatch, + AllowLive: source.AllowLive, RequireOAuthOnly: source.RequireOAuthOnly, RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index c6c48d1e1f..44db1afdc7 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -162,6 +162,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing SupportedModelScopes: []string{"claude", "gemini_text"}, SortOrder: 9, AllowMessagesDispatch: true, + AllowLive: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 49c5272ed0..bd907125a5 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -252,6 +252,7 @@ type CreateGroupInput struct { SupportedModelScopes []string // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool + AllowLive bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -312,6 +313,7 @@ type UpdateGroupInput struct { SupportedModelScopes *[]string // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool + AllowLive *bool DefaultMappedModel *string RequireOAuthOnly *bool RequirePrivacySet *bool diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index fbd998cade..f809c68ccf 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -33,6 +33,9 @@ func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context, } func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) CreateWithEmailAliasGuard(context.Context, *User) error { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) { panic("unexpected") } @@ -81,6 +84,9 @@ func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) ExistsByEmailAlias(context.Context, string) (bool, error) { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index be64576d41..c1968c98f2 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -13,18 +13,21 @@ import ( ) type userRepoStub struct { - user *User - getErr error - createErr error - deleteErr error - exists bool - existsErr error - nextID int64 - created []*User - updated []*User - deletedIDs []int64 - usersByEmail map[string]*User - getByEmailErr error + user *User + getErr error + createErr error + deleteErr error + exists bool + existsErr error + aliasExists bool + aliasErr error + guardedCreates int + nextID int64 + created []*User + updated []*User + deletedIDs []int64 + usersByEmail map[string]*User + getByEmailErr error } func (s *userRepoStub) Create(ctx context.Context, user *User) error { @@ -43,6 +46,17 @@ func (s *userRepoStub) Create(ctx context.Context, user *User) error { return nil } +func (s *userRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + s.guardedCreates++ + if s.aliasErr != nil { + return s.aliasErr + } + if s.aliasExists { + return ErrEmailExists + } + return s.Create(ctx, user) +} + func (s *userRepoStub) GetByID(ctx context.Context, id int64) (*User, error) { if s.getErr != nil { return nil, s.getErr @@ -144,6 +158,13 @@ func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, e return s.exists, nil } +func (s *userRepoStub) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + if s.aliasErr != nil { + return false, s.aliasErr + } + return s.aliasExists, nil +} + func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index 1c6c474968..806f0293a7 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -35,6 +35,10 @@ type emailSyncRepoStub struct { replaceErr error } +func (s *emailSyncRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + return s.Create(ctx, user) +} + func (s *emailSyncRepoStub) Create(_ context.Context, user *User) error { if s.nextID != 0 && user.ID == 0 { user.ID = s.nextID @@ -109,6 +113,10 @@ func (s *emailSyncRepoStub) UpdateConcurrency(context.Context, int64, int) error func (s *emailSyncRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (s *emailSyncRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} + func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 5b28928f79..c0b4faef52 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -957,6 +957,7 @@ func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatfo Platform: PlatformAnthropic, RateMultiplier: 1.0, AllowMessagesDispatch: true, + AllowLive: true, DefaultMappedModel: "gpt-5.4", MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ OpusMappedModel: "gpt-5.4", @@ -966,6 +967,7 @@ func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatfo require.NotNil(t, group) require.NotNil(t, repo.created) require.False(t, repo.created.AllowMessagesDispatch) + require.False(t, repo.created.AllowLive) require.Empty(t, repo.created.DefaultMappedModel) require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig) } @@ -977,6 +979,7 @@ func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChange Platform: PlatformOpenAI, Status: StatusActive, AllowMessagesDispatch: true, + AllowLive: true, DefaultMappedModel: "gpt-5.4", MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ SonnetMappedModel: "gpt-5.3-codex", @@ -993,6 +996,7 @@ func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChange require.NotNil(t, repo.updated) require.Equal(t, PlatformAnthropic, repo.updated.Platform) require.False(t, repo.updated.AllowMessagesDispatch) + require.False(t, repo.updated.AllowLive) require.Empty(t, repo.updated.DefaultMappedModel) require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.updated.MessagesDispatchModelConfig) } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 94799de52d..924fc20fcd 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -94,6 +94,7 @@ type APIKeyAuthGroupSnapshot struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + AllowLive bool `json:"allow_live"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 94c4276bf1..fd5fda22c7 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 16 // v16: include group reasoning effort ceiling and mappings +const apiKeyAuthSnapshotVersion = 17 // v17: include the OpenAI group Live gate type apiKeyAuthCacheConfig struct { l1Size int @@ -409,6 +409,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) MCPXMLInject: apiKey.Group.MCPXMLInject, SupportedModelScopes: apiKey.Group.SupportedModelScopes, AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, + AllowLive: apiKey.Group.AllowLive, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -495,6 +496,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho MCPXMLInject: snapshot.Group.MCPXMLInject, SupportedModelScopes: snapshot.Group.SupportedModelScopes, AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, + AllowLive: snapshot.Group.AllowLive, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index 24d0eeee75..af63f7c433 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -132,7 +132,8 @@ func (s *AuthService) RegisterOAuthEmailAccount( return nil, nil, err } - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。 + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error()) return nil, nil, ErrServiceUnavailable @@ -159,7 +160,7 @@ func (s *AuthService) RegisterOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { if errors.Is(err, ErrEmailExists) { return nil, nil, ErrEmailExists } @@ -211,7 +212,8 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( return nil, nil, err } - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。 + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { return nil, nil, ErrServiceUnavailable } @@ -241,7 +243,7 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { if errors.Is(err, ErrEmailExists) { return nil, nil, ErrEmailExists } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 6f97b1e85f..8ce1711ce1 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -189,8 +189,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return "", nil, ErrServiceUnavailable @@ -224,7 +224,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw Status: StatusActive, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { // 优先检查邮箱冲突错误(竞态条件下可能发生) if errors.Is(err, ErrEmailExists) { return "", nil, ErrEmailExists @@ -296,8 +296,8 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale . return err } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return ErrServiceUnavailable @@ -337,8 +337,8 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc return nil, err } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化;在发信前拦截,避免批量脚本消耗发信配额) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return nil, ErrServiceUnavailable diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index eb6a35199b..deeda002df 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -874,6 +874,10 @@ func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub { func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil } +func (s *emailBindUserRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + return s.Create(ctx, user) +} + func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) { s.mu.Lock() defer s.mu.Unlock() @@ -957,6 +961,18 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) ( return ok, nil } +func (s *emailBindUserRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + identity := service.NormalizeEmailForAliasDedup(email) + for stored := range s.usersByEmail { + if service.NormalizeEmailForAliasDedup(stored) == identity { + return true, nil + } + } + return false, nil +} + func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index af5bdff3e6..eeb5f3b0a4 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -365,6 +365,30 @@ func TestAuthService_Register_EmailExists(t *testing.T) { require.ErrorIs(t, err, ErrEmailExists) } +func TestAuthService_Register_AliasDuplicateRejected(t *testing.T) { + repo := &userRepoStub{aliasExists: true} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil, nil) + + _, _, err := service.Register(context.Background(), "some.one+bulk294@gmail.com", "password") + require.ErrorIs(t, err, ErrEmailExists) + require.Empty(t, repo.created) +} + +func TestAuthService_Register_UsesAliasGuardedCreate(t *testing.T) { + // 注册必须走带别名兜底的创建路径:服务层前置查重与写入之间存在竞态窗口。 + repo := &userRepoStub{nextID: 91} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil, nil) + + _, user, err := service.Register(context.Background(), "newuser@gmail.com", "password") + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, 1, repo.guardedCreates) +} + func TestAuthService_Register_CheckEmailError(t *testing.T) { repo := &userRepoStub{existsErr: errors.New("db down")} service := newAuthService(repo, map[string]string{ diff --git a/backend/internal/service/batch_image.go b/backend/internal/service/batch_image.go index af0206b8b0..130428f18e 100644 --- a/backend/internal/service/batch_image.go +++ b/backend/internal/service/batch_image.go @@ -137,6 +137,7 @@ type BatchImageJob struct { IdempotencyKey *string RequestHash *string ManifestHash *string + SessionID *string RetryCount int Version int @@ -196,6 +197,7 @@ type CreateBatchImageJobParams struct { IdempotencyKey *string RequestHash *string ManifestHash *string + SessionID *string RetryCount int diff --git a/backend/internal/service/batch_image_processor_test.go b/backend/internal/service/batch_image_processor_test.go index f54a1a99ec..ca3a583c99 100644 --- a/backend/internal/service/batch_image_processor_test.go +++ b/backend/internal/service/batch_image_processor_test.go @@ -425,6 +425,7 @@ func (r *fakeBatchImageRepository) CreateBatchImageJob(_ context.Context, params Currency: params.Currency, IdempotencyKey: params.IdempotencyKey, RequestHash: params.RequestHash, + SessionID: params.SessionID, CreatedAt: time.Now(), } r.jobs[job.BatchID] = job diff --git a/backend/internal/service/batch_image_public.go b/backend/internal/service/batch_image_public.go index 2960d4cd9f..580d1f4934 100644 --- a/backend/internal/service/batch_image_public.go +++ b/backend/internal/service/batch_image_public.go @@ -57,6 +57,7 @@ type BatchImageSubmitRequest struct { AspectRatio string `json:"aspect_ratio"` ImageSize string `json:"image_size"` Metadata map[string]string `json:"metadata"` + SessionID *string `json:"-"` } type BatchImageSubmitItem struct { @@ -281,6 +282,7 @@ func (s *BatchImagePublicService) Submit(ctx context.Context, owner BatchImageOw HoldID: &holdID, IdempotencyKey: batchImageOptionalStringPtr(idempotencyKey), RequestHash: batchImageStringPtr(requestHash), + SessionID: normalized.SessionID, }) if err != nil { return nil, err diff --git a/backend/internal/service/batch_image_public_test.go b/backend/internal/service/batch_image_public_test.go index 2e5b930a7b..a69468f846 100644 --- a/backend/internal/service/batch_image_public_test.go +++ b/backend/internal/service/batch_image_public_test.go @@ -27,8 +27,10 @@ func TestBatchImagePublicService_Submit(t *testing.T) { t.Run("accepts valid request stores refs and enqueues once", func(t *testing.T) { svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true) + req := validBatchImageSubmitRequest() + req.SessionID = batchImageStringPtr("batch-session-123") - got, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "") + got, err := svc.Submit(ctx, testBatchImageOwner(), req, "") require.NoError(t, err) require.Equal(t, "image.batch", got.Object) require.Equal(t, "queued", got.Status) @@ -61,6 +63,7 @@ func TestBatchImagePublicService_Submit(t *testing.T) { require.InDelta(t, 0.6, job.HoldMultiplier, 1e-12) require.InDelta(t, 0.125, job.BillableUnitPrice, 1e-12) require.InDelta(t, 0.15, job.HoldUnitPrice, 1e-12) + require.Equal(t, "batch-session-123", batchImageDerefString(job.SessionID)) }) t.Run("combines user group image rate account rate discount and hold margin", func(t *testing.T) { @@ -389,15 +392,18 @@ func TestBatchImagePublicService_Submit(t *testing.T) { }) t.Run("idempotency returns same batch without provider resubmit", func(t *testing.T) { - svc, _, queue, gemini, _ := newTestBatchImagePublicService(true) + svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true) req := validBatchImageSubmitRequest() + req.SessionID = batchImageStringPtr("original-session") first, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key") require.NoError(t, err) + req.SessionID = batchImageStringPtr("retry-session") second, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key") require.NoError(t, err) require.Equal(t, first.ID, second.ID) + require.Equal(t, "original-session", batchImageDerefString(repo.jobs[first.ID].SessionID)) require.Len(t, gemini.submits, 1) require.Equal(t, []string{first.ID}, queue.enqueued) }) diff --git a/backend/internal/service/batch_image_settlement.go b/backend/internal/service/batch_image_settlement.go index 26d2abb0fd..e9b8a3685e 100644 --- a/backend/internal/service/batch_image_settlement.go +++ b/backend/internal/service/batch_image_settlement.go @@ -277,6 +277,7 @@ func (s *BatchImageSettlementService) recordUsageLog(ctx context.Context, job *B RequestType: RequestTypeSync, BillingMode: &billingMode, ImageSize: &imageSize, + SessionID: job.SessionID, CreatedAt: createdAt, } writeUsageLogBestEffort(ctx, s.UsageLogRepo, usageLog, "service.batch_image_settlement") diff --git a/backend/internal/service/batch_image_settlement_test.go b/backend/internal/service/batch_image_settlement_test.go index a1fe3e899d..f1fc3a15eb 100644 --- a/backend/internal/service/batch_image_settlement_test.go +++ b/backend/internal/service/batch_image_settlement_test.go @@ -18,9 +18,14 @@ func TestBatchImageSettlementService_SettlesAndChargesSuccessfulImagesOnly(t *te job.SuccessCount = 3 job.FailCount = 2 job.ItemCount = 5 + job.SessionID = batchImageStringPtr("batch-settlement-session") repo.jobs[job.BatchID] = job billing := &fakeBatchImageBillingRepo{} - svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}} + usageLogs := &openAIRecordUsageLogRepoStub{} + svc := &BatchImageSettlementService{ + Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}, + UsageLogRepo: usageLogs, + } result, err := svc.Settle(context.Background(), job.BatchID) require.NoError(t, err) @@ -32,6 +37,7 @@ func TestBatchImageSettlementService_SettlesAndChargesSuccessfulImagesOnly(t *te require.Equal(t, 0.75, *repo.jobs[job.BatchID].ActualCost) require.NotEmpty(t, batchImageDerefString(repo.jobs[job.BatchID].ManifestHash)) require.NotNil(t, repo.jobs[job.BatchID].SettledAt) + require.Equal(t, "batch-settlement-session", batchImageDerefString(usageLogs.lastLog.SessionID)) require.Len(t, billing.captures, 1) require.Equal(t, int64(321), billing.captures[0].APIKeyID) require.Equal(t, job.UserID, billing.captures[0].UserID) diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index 595f687ac1..d2b491e632 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -347,8 +347,13 @@ func removeCustomFieldFromTools(body []byte) []byte { } // claudeVersionRe 匹配 Claude 模型 ID 中的版本号部分 -// 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式 -var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)[-.](\d+)`) +// 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式; +// minor 可选,用于覆盖只有主版本号的新模型 ID(claude-opus-5 / claude-sonnet-5), +// 此时 matches[2] 为空串,调用方 strconv.Atoi 得到 minor=0。 +// 缺少可选 minor 时这类 ID 完全不匹配,会被当作"旧模型"降级处理: +// thinking.enabled 不转 adaptive(Opus 5 上游已移除 budget_tokens,直接 400)、 +// cache_control.ttl 被剥离、tool search 被过滤。 +var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)(?:[-.](\d+))?`) // isBedrockClaude45OrNewer 判断 Bedrock 模型 ID 是否为 Claude 4.5 或更新版本 // Claude 4.5+ 支持 cache_control 中的 ttl 字段("5m" 和 "1h") diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 8ebe754bdb..98eb9c3ec3 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -258,6 +258,11 @@ func (s *BillingService) initFallbackPricing() { // Claude 4.7 Opus (暂与4.6同价,待官方定价更新) s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"] + // Claude 4.8 Opus / Claude Opus 5(官方同价:$5 输入 / $25 输出 per MTok)。 + // 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。 + s.fallbackPrices["claude-opus-4.8"] = s.fallbackPrices["claude-opus-4.7"] + s.fallbackPrices["claude-opus-5"] = s.fallbackPrices["claude-opus-4.8"] + // Gemini 3.1 Pro s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{ InputPricePerToken: 2e-6, // $2 per MTok @@ -588,6 +593,13 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 按模型系列匹配 if strings.Contains(modelLower, "opus") { + // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 + if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { + return s.fallbackPrices["claude-opus-5"] + } + if strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") { + return s.fallbackPrices["claude-opus-4.8"] + } if strings.Contains(modelLower, "4.7") || strings.Contains(modelLower, "4-7") { return s.fallbackPrices["claude-opus-4.7"] } diff --git a/backend/internal/service/claude_opus5_test.go b/backend/internal/service/claude_opus5_test.go new file mode 100644 index 0000000000..adf919feb7 --- /dev/null +++ b/backend/internal/service/claude_opus5_test.go @@ -0,0 +1,139 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Claude Opus 5 官方定价(USD per token):$5 输入 / $25 输出 per MTok。 +const ( + opus5InputPricePerToken = 5e-6 + opus5OutputPricePerToken = 25e-6 + opus5CacheCreationPricePerToken = 6.25e-6 + opus5CacheReadPricePerToken = 0.5e-6 +) + +// TestClaudeOpus5_FamilyFallbackDoesNotUseOpus4Rates 覆盖定价数据里还没有 +// claude-opus-5 条目的场景(远端价格表滞后于模型发布)。 +// 修复前 matchByModelFamily 会把 claude-opus-5 归到 "opus-4" 系列, +// 按 $15/$75 计费 —— 输入与输出双双 3 倍超收。 +func TestClaudeOpus5_FamilyFallbackDoesNotUseOpus4Rates(t *testing.T) { + svc := NewBillingService(&config.Config{}, &PricingService{ + pricingData: map[string]*LiteLLMModelPricing{ + // 有 4.8(同价),故意不放 claude-opus-5 + "claude-opus-4-8": { + InputCostPerToken: opus5InputPricePerToken, + OutputCostPerToken: opus5OutputPricePerToken, + CacheCreationInputTokenCost: opus5CacheCreationPricePerToken, + CacheReadInputTokenCost: opus5CacheReadPricePerToken, + }, + // 修复前会被误命中的旧 Opus 条目($15/$75) + "claude-opus-4-1": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6}, + "claude-opus-4-20250514": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6}, + "claude-3-opus-20240229": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6}, + }, + }) + + for _, model := range []string{"claude-opus-5", "us.anthropic.claude-opus-5-v1"} { + t.Run(model, func(t *testing.T) { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.NotNil(t, pricing) + assert.InDelta(t, opus5InputPricePerToken, pricing.InputPricePerToken, 1e-12) + assert.InDelta(t, opus5OutputPricePerToken, pricing.OutputPricePerToken, 1e-12) + }) + } +} + +// TestClaudeOpus5_HardcodedFallbackPricing 覆盖动态价格服务完全不可用时的 +// 硬编码兜底表。同时锁定不能被 "opus-5" 子串误伤的相邻型号。 +func TestClaudeOpus5_HardcodedFallbackPricing(t *testing.T) { + // pricingService 为 nil,强制走硬编码兜底表 + svc := NewBillingService(&config.Config{}, nil) + + tests := []struct { + model string + input float64 + output float64 + }{ + {"claude-opus-5", opus5InputPricePerToken, opus5OutputPricePerToken}, + {"us.anthropic.claude-opus-5-v1", opus5InputPricePerToken, opus5OutputPricePerToken}, + // 4.8 与 5 同价;修复前兜底表缺失,会掉到 claude-3-opus 的 $15/$75 + {"claude-opus-4-8", opus5InputPricePerToken, opus5OutputPricePerToken}, + // 相邻型号不能被 "opus-5" 误匹配 + {"claude-opus-4-5-20251101", 5e-6, 25e-6}, + {"claude-opus-4-1-20250805", 15e-6, 75e-6}, + {"claude-3-opus-20240229", 15e-6, 75e-6}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + pricing, err := svc.GetModelPricing(tt.model) + require.NoError(t, err) + require.NotNil(t, pricing) + assert.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12) + assert.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12) + }) + } + + opus5, err := svc.GetModelPricing("claude-opus-5") + require.NoError(t, err) + assert.InDelta(t, opus5CacheCreationPricePerToken, opus5.CacheCreationPricePerToken, 1e-12) + assert.InDelta(t, opus5CacheReadPricePerToken, opus5.CacheReadPricePerToken, 1e-12) +} + +// TestClaudeOpus5_BedrockCapabilityGates 锁定只有主版本号的模型 ID +// (claude-opus-5 / claude-sonnet-5)能被版本闸门识别。 +// 修复前 claudeVersionRe 强制要求 major-minor,这类 ID 完全不匹配, +// 会被当成旧模型降级。 +func TestClaudeOpus5_BedrockCapabilityGates(t *testing.T) { + tests := []struct { + modelID string + claude45Newer bool + toolSearch bool + opus47Newer bool + }{ + {"claude-opus-5", true, true, true}, + {"us.anthropic.claude-opus-5-v1", true, true, true}, + {"eu.anthropic.claude-opus-5-v1", true, true, true}, + {"claude-sonnet-5", true, true, false}, + {"us.anthropic.claude-sonnet-5-v1", true, true, false}, + // 回归保护:旧模型不能因为 minor 可选而被误判为新版本 + {"anthropic.claude-opus-4-1-v1", false, false, false}, + {"anthropic.claude-sonnet-4-0-v1", false, false, false}, + {"anthropic.claude-3-opus-20240229-v1:0", false, false, false}, + {"us.anthropic.claude-opus-4-8-v1", true, true, true}, + // Haiku 不支持 tool search + {"us.anthropic.claude-haiku-4-5-20251001-v1:0", true, false, false}, + } + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + assert.Equal(t, tt.claude45Newer, isBedrockClaude45OrNewer(tt.modelID), "isBedrockClaude45OrNewer") + assert.Equal(t, tt.toolSearch, bedrockModelSupportsToolSearch(tt.modelID), "bedrockModelSupportsToolSearch") + assert.Equal(t, tt.opus47Newer, isBedrockOpus47OrNewer(tt.modelID), "isBedrockOpus47OrNewer") + }) + } +} + +// TestClaudeOpus5_BedrockThinkingConvertedToAdaptive 验证 Opus 5 在 Bedrock 路径上 +// 会把 thinking.type=enabled 转成 adaptive 并移除 budget_tokens。 +// 上游 Opus 5 已移除 budget_tokens,透传过去会直接 400。 +func TestClaudeOpus5_BedrockThinkingConvertedToAdaptive(t *testing.T) { + body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000}}`) + got := sanitizeBedrockThinking(body, "us.anthropic.claude-opus-5-v1") + + assert.JSONEq(t, `{"thinking":{"type":"adaptive"}}`, string(got)) +} + +// TestClaudeOpus5_CatalogAndBedrockMapping 锁定模型清单与 Bedrock 默认映射。 +func TestClaudeOpus5_CatalogAndBedrockMapping(t *testing.T) { + assert.Contains(t, claude.DefaultModelIDs(), "claude-opus-5") + + mapped, ok := domain.DefaultBedrockModelMapping["claude-opus-5"] + require.True(t, ok, "claude-opus-5 missing from DefaultBedrockModelMapping") + assert.Equal(t, "us.anthropic.claude-opus-5-v1", mapped) +} diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index f4e3aeb00d..4dad7c39ee 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -168,6 +168,10 @@ func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User) panic("unexpected Create call") } +func (r *contentModerationTestUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + panic("unexpected CreateWithEmailAliasGuard call") +} + func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) { if r.user == nil { return nil, ErrUserNotFound @@ -257,6 +261,10 @@ func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email panic("unexpected ExistsByEmail call") } +func (r *contentModerationTestUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + panic("unexpected ExistsByEmailAlias call") +} + func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index b687696cfe..3722ec5189 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1623,3 +1623,114 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAft require.True(t, result.clientDisconnect) require.Equal(t, 8, result.usage.InputTokens) } + +func TestGatewayService_AnthropicAPIKeyPassthrough_TransportErrorRecordsOllamaActivity(t *testing.T) { + gin.SetMode(gin.TestMode) + deferred := NewDeferredService(nil, nil, time.Second) + upstream := &anthropicHTTPUpstreamRecorder{err: errors.New("dial tcp timeout")} + svc := &GatewayService{ + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + httpUpstream: upstream, + deferredService: deferred, + } + + ollama := &Account{ + ID: 601, Name: "ollama-anthropic", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + Extra: map[string]any{"anthropic_passthrough": true}, + Status: StatusActive, Schedulable: true, + } + other := newAnthropicAPIKeyAccountForTest() + other.ID = 602 + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + _, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now()) + require.Error(t, err) + + rec2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(rec2) + c2.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + _, err = svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c2, other, []byte(`{"model":"x"}`), "x", "x", false, time.Now()) + require.Error(t, err) + + _, ok := deferred.lastUsedUpdates.Load(int64(601)) + require.True(t, ok, "Anthropic passthrough transport error on Ollama account must record activity") + _, ok = deferred.lastUsedUpdates.Load(int64(602)) + require.False(t, ok, "non-Ollama Anthropic passthrough transport error must not record Ollama activity") +} + +func TestGatewayService_AnthropicAPIKeyPassthrough_ContextCanceledSkipsOllamaActivity(t *testing.T) { + gin.SetMode(gin.TestMode) + deferred := NewDeferredService(nil, nil, time.Second) + upstream := &anthropicHTTPUpstreamRecorder{err: context.Canceled} + svc := &GatewayService{ + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + httpUpstream: upstream, + deferredService: deferred, + } + ollama := &Account{ + ID: 603, Name: "ollama-canceled", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + Extra: map[string]any{"anthropic_passthrough": true}, + Status: StatusActive, Schedulable: true, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + _, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now()) + + require.Error(t, err) + _, ok := deferred.lastUsedUpdates.Load(int64(603)) + require.False(t, ok, "context.Canceled on Anthropic passthrough must not count as Ollama activity") +} + +func TestGatewayService_AnthropicAPIKeyPassthrough_Non2xxRecordsOllamaActivity(t *testing.T) { + gin.SetMode(gin.TestMode) + deferred := NewDeferredService(nil, nil, time.Second) + // 400 is non-retryable / non-failover for default API-key accounts, so it reaches handleErrorResponse. + upstream := &anthropicHTTPUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"invalid_request_error","message":"bad"}}`)), + }, + } + svc := &GatewayService{ + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + httpUpstream: upstream, + deferredService: deferred, + rateLimitService: &RateLimitService{}, + } + ollama := &Account{ + ID: 604, Name: "ollama-400", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + Extra: map[string]any{"anthropic_passthrough": true}, + Status: StatusActive, Schedulable: true, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + _, _ = svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now()) + + _, ok := deferred.lastUsedUpdates.Load(int64(604)) + require.True(t, ok, "Anthropic passthrough non-2xx on Ollama account must record activity via handleErrorResponse") +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 2eb7a97e3b..8d7610b936 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -114,6 +114,9 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( if resp != nil && resp.Body != nil { _ = resp.Body.Close() } + if !errors.Is(err, context.Canceled) { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 2e6093d18e..3005d93fa1 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -369,6 +369,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if resp != nil && resp.Body != nil { _ = resp.Body.Close() } + // Transport attempt left local validation; count Ollama Cloud activity. + if !errors.Is(err, context.Canceled) { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } // Ensure the client receives an error response (handlers assume Forward writes on non-failover errors). safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 03f757dabc..a32c007e03 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -356,6 +356,8 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { + // Upstream returned a non-success HTTP status; count Ollama Cloud activity. + scheduleOllamaCloudUsageActivity(s.deferredService, account) body, readErr := s.readUpstreamErrorBody(resp) if readErr != nil { // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1606f0f3c1..f445814d01 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -46,6 +46,7 @@ type RecordUsageInput struct { UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent IPAddress string // 请求的客户端 IP 地址 + SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联 RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额 @@ -572,6 +573,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu UpstreamEndpoint: input.UpstreamEndpoint, UserAgent: input.UserAgent, IPAddress: input.IPAddress, + SessionID: input.SessionID, RequestPayloadHash: input.RequestPayloadHash, ForceCacheBilling: input.ForceCacheBilling, APIKeyService: input.APIKeyService, @@ -591,6 +593,7 @@ type RecordUsageLongContextInput struct { UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent IPAddress string // 请求的客户端 IP 地址 + SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联 RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 LongContextThreshold int // 长上下文阈值(如 200000) LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) @@ -613,6 +616,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * UpstreamEndpoint: input.UpstreamEndpoint, UserAgent: input.UserAgent, IPAddress: input.IPAddress, + SessionID: input.SessionID, RequestPayloadHash: input.RequestPayloadHash, ForceCacheBilling: input.ForceCacheBilling, APIKeyService: input.APIKeyService, @@ -635,6 +639,7 @@ type recordUsageCoreInput struct { UpstreamEndpoint string UserAgent string IPAddress string + SessionID string RequestPayloadHash string ForceCacheBilling bool APIKeyService APIKeyQuotaUpdater @@ -1010,6 +1015,7 @@ func (s *GatewayService) buildRecordUsageLog( ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), UserAgent: optionalTrimmedStringPtr(input.UserAgent), IPAddress: optionalTrimmedStringPtr(input.IPAddress), + SessionID: optionalTrimmedStringPtr(input.SessionID), GroupID: apiKey.GroupID, SubscriptionID: optionalSubscriptionID(subscription), CreatedAt: time.Now(), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index de996649b8..5f868a5d66 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -473,7 +473,7 @@ func geminiResponseToChatCompletions( rawData []byte, usageOverride *ClaudeUsage, ) (*apicompat.ChatCompletionsResponse, *ClaudeUsage, error) { - claudeRespMap, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, rawData) + claudeRespMap, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, rawData, true) if usageOverride != nil && (usageOverride.InputTokens > 0 || usageOverride.OutputTokens > 0 || usageOverride.CacheReadInputTokens > 0) { usage = usageOverride if usageMap, ok := claudeRespMap["usage"].(map[string]any); ok { diff --git a/backend/internal/service/gemini_chat_completions_compat_service_test.go b/backend/internal/service/gemini_chat_completions_compat_service_test.go new file mode 100644 index 0000000000..e4ebf7a838 --- /dev/null +++ b/backend/internal/service/gemini_chat_completions_compat_service_test.go @@ -0,0 +1,163 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGeminiResponseToChatCompletionsPreservesInlineData(t *testing.T) { + tests := []struct { + name string + parts []any + want string + }{ + { + name: "image only", + parts: []any{ + map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": "aW1hZ2U="}}, + }, + want: "![image](data:image/png;base64,aW1hZ2U=)", + }, + { + name: "text and image", + parts: []any{ + map[string]any{"text": "rendered image:\n"}, + map[string]any{"inlineData": map[string]any{"mimeType": "image/webp", "data": "d2VicA=="}}, + }, + want: "rendered image:\n![image](data:image/webp;base64,d2VicA==)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + geminiResp := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{"parts": tt.parts}, + "finishReason": "STOP", + }}, + } + rawData, err := json.Marshal(geminiResp) + require.NoError(t, err) + + got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil) + require.NoError(t, err) + require.Len(t, got.Choices, 1) + + var content string + require.NoError(t, json.Unmarshal(got.Choices[0].Message.Content, &content)) + require.Equal(t, tt.want, content) + require.Equal(t, "stop", got.Choices[0].FinishReason) + }) + } +} + +func TestGeminiResponseToChatCompletionsOmitsInvalidInlineData(t *testing.T) { + tests := []struct { + name string + inlineData map[string]any + }{ + { + name: "unsupported MIME type", + inlineData: map[string]any{"mimeType": "image/svg+xml", "data": "PHN2Zz48L3N2Zz4="}, + }, + { + name: "malformed MIME type", + inlineData: map[string]any{"mimeType": "image/png; charset=utf-8", "data": "aW1hZ2U="}, + }, + { + name: "malformed base64", + inlineData: map[string]any{"mimeType": "image/png", "data": "not-valid-base64!!!"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + geminiResp := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{"parts": []any{map[string]any{"text": "before"}, map[string]any{"inlineData": tt.inlineData}, map[string]any{"text": "after"}}}, + "finishReason": "STOP", + }}, + } + rawData, err := json.Marshal(geminiResp) + require.NoError(t, err) + + got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil) + require.NoError(t, err) + + var content string + require.NoError(t, json.Unmarshal(got.Choices[0].Message.Content, &content)) + require.Equal(t, "beforeafter", content) + }) + } +} + +func TestConvertGeminiToClaudeMessageOmitsInlineDataForAnthropicMessages(t *testing.T) { + geminiResp := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{"parts": []any{ + map[string]any{"text": "before"}, + map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": "aW1hZ2U="}}, + map[string]any{"functionCall": map[string]any{"name": "get_weather", "args": map[string]any{"city": "Paris"}}}, + map[string]any{"text": "after"}, + }}, + "finishReason": "STOP", + }}, + } + rawData, err := json.Marshal(geminiResp) + require.NoError(t, err) + + withInlineData, _ := convertGeminiToClaudeMessage(geminiResp, "gemini-test", rawData, true) + contentWithInlineData, ok := withInlineData["content"].([]any) + require.True(t, ok) + require.Len(t, contentWithInlineData, 4) + require.Equal(t, map[string]any{"type": "text", "text": "before"}, contentWithInlineData[0]) + require.Equal(t, map[string]any{"type": "text", "text": "![image](data:image/png;base64,aW1hZ2U=)"}, contentWithInlineData[1]) + toolUse, ok := contentWithInlineData[2].(map[string]any) + require.True(t, ok) + require.Equal(t, "tool_use", toolUse["type"]) + require.Equal(t, "get_weather", toolUse["name"]) + require.Equal(t, map[string]any{"type": "text", "text": "after"}, contentWithInlineData[3]) + + withoutInlineData, _ := convertGeminiToClaudeMessage(geminiResp, "gemini-test", rawData, false) + contentWithoutInlineData, ok := withoutInlineData["content"].([]any) + require.True(t, ok) + require.Len(t, contentWithoutInlineData, 3) + require.Equal(t, map[string]any{"type": "text", "text": "before"}, contentWithoutInlineData[0]) + toolUseWithoutInlineData, ok := contentWithoutInlineData[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "tool_use", toolUseWithoutInlineData["type"]) + require.Equal(t, "get_weather", toolUseWithoutInlineData["name"]) + require.Equal(t, map[string]any{"type": "text", "text": "after"}, contentWithoutInlineData[2]) +} + +func TestGeminiResponseToChatCompletionsRetainsTextAndToolBehavior(t *testing.T) { + geminiResp := map[string]any{ + "candidates": []any{map[string]any{ + "content": map[string]any{"parts": []any{ + map[string]any{"text": "checking"}, + map[string]any{"functionCall": map[string]any{ + "name": "get_weather", + "args": map[string]any{"city": "Paris"}, + }}, + }}, + "finishReason": "STOP", + }}, + } + rawData, err := json.Marshal(geminiResp) + require.NoError(t, err) + + got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil) + require.NoError(t, err) + require.Len(t, got.Choices, 1) + + choice := got.Choices[0] + var content string + require.NoError(t, json.Unmarshal(choice.Message.Content, &content)) + require.Equal(t, "checking", content) + require.Equal(t, "tool_calls", choice.FinishReason) + require.Len(t, choice.Message.ToolCalls, 1) + require.Equal(t, "get_weather", choice.Message.ToolCalls[0].Function.Name) + require.JSONEq(t, `{"city":"Paris"}`, choice.Message.ToolCalls[0].Function.Arguments) +} diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 2aaba767e9..1a3821847d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -1068,7 +1069,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream") } collectedBytes, _ := json.Marshal(collected) - claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes) + claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes, false) c.JSON(http.StatusOK, claudeResp) usage = usageObj2 if usageObj != nil && (usageObj.InputTokens > 0 || usageObj.OutputTokens > 0) { @@ -1965,7 +1966,7 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context, return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") } - claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, unwrappedBody) + claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, unwrappedBody, false) c.JSON(http.StatusOK, claudeResp) return usage, nil @@ -2717,7 +2718,7 @@ func unwrapGeminiResponse(raw []byte) ([]byte, error) { return raw, nil } -func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string, rawData []byte) (map[string]any, *ClaudeUsage) { +func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string, rawData []byte, includeInlineData bool) (map[string]any, *ClaudeUsage) { usage := extractGeminiUsage(rawData) if usage == nil { usage = &ClaudeUsage{} @@ -2740,6 +2741,16 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin "text": text, }) } + if inlineData, ok := pm["inlineData"].(map[string]any); includeInlineData && ok { + mimeType, _ := inlineData["mimeType"].(string) + data, _ := inlineData["data"].(string) + if isGeminiInlineImageMIMEType(mimeType) && isValidBase64(data) { + contentBlocks = append(contentBlocks, map[string]any{ + "type": "text", + "text": fmt.Sprintf("![image](data:%s;base64,%s)", mimeType, data), + }) + } + } if fc, ok := pm["functionCall"].(map[string]any); ok { name, _ := fc["name"].(string) if strings.TrimSpace(name) == "" { @@ -2782,6 +2793,23 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin return resp, usage } +func isGeminiInlineImageMIMEType(mimeType string) bool { + switch mimeType { + case "image/gif", "image/jpeg", "image/png", "image/webp": + return true + default: + return false + } +} + +func isValidBase64(data string) bool { + if data == "" { + return false + } + _, err := base64.StdEncoding.DecodeString(data) + return err == nil +} + func extractGeminiUsage(data []byte) *ClaudeUsage { usage := gjson.GetBytes(data, "usageMetadata") if !usage.Exists() { diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index e428035db5..b71e6b9e84 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -82,6 +82,7 @@ type Group struct { // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool + AllowLive bool RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string diff --git a/backend/internal/service/ollama_cloud_usage.go b/backend/internal/service/ollama_cloud_usage.go index 2b50bab62c..ad0f647ae6 100644 --- a/backend/internal/service/ollama_cloud_usage.go +++ b/backend/internal/service/ollama_cloud_usage.go @@ -32,10 +32,20 @@ const ( OllamaCloudUsageAutoRefreshExtraKey = "ollama_cloud_usage_auto_refresh" OllamaCloudUsageSnapshotExtraKey = "ollama_cloud_usage_snapshot" + // OllamaCloudUsageMinFetchInterval is the hard floor between two successful + // fetches of the same group, mirroring the floor nextOllamaCloudUsageDelay + // applies to next_refresh_at. Activity may bring a refresh forward to this + // bound but never past it. Exported so the repository can apply the same + // floor inside the SQL due filter. + OllamaCloudUsageMinFetchInterval = ollamaCloudUsageMinIntervalMinutes * time.Minute + ollamaCloudUsageSettingsURL = "https://ollama.com/settings" ollamaCloudUsageDefaultIntervalMinutes = 60 ollamaCloudUsageMinIntervalMinutes = 15 ollamaCloudUsageMaxIntervalMinutes = 24 * 60 + ollamaCloudUsageDefaultDebounceMinutes = 1 + ollamaCloudUsageMinDebounceMinutes = 1 + ollamaCloudUsageMaxDebounceMinutes = 60 ollamaCloudUsageCycleInterval = time.Minute ollamaCloudUsageManualRefreshInterval = 30 * time.Second ollamaCloudUsageRequestTimeout = 15 * time.Second @@ -76,10 +86,15 @@ const ( OllamaCloudUsageStatusFailed = "failed" ) -// OllamaCloudUsageSettings controls the opt-in periodic refresh runner. +// OllamaCloudUsageSettings controls the opt-in request-driven refresh runner. +// +// IntervalMinutes is the max-wait bound: when model requests keep arriving and +// the trailing debounce keeps sliding, a refresh is forced after this long. +// DebounceMinutes is the quiet period after the latest request in a group. type OllamaCloudUsageSettings struct { Enabled bool `json:"enabled"` - IntervalMinutes int `json:"interval_minutes"` + IntervalMinutes int `json:"interval_minutes"` // max wait while requests continue + DebounceMinutes int `json:"debounce_minutes"` // trailing quiet period after last request } // OllamaCloudUsageWindow is a narrow, sanitized view of one official usage window. @@ -114,6 +129,12 @@ type OllamaCloudUsageData struct { } // OllamaCloudUsageSnapshot is the only usage observation persisted in account extra. +// +// NextRefreshAt remains a persisted compatibility field. For status=ok it is a +// max-wait horizon marker only; automatic success refreshes are driven by model +// request activity (group last_used_at + debounce/max-wait), not by this field +// alone. For failed/unauthorized snapshots it is the failure not-before time +// (Retry-After / exponential backoff) and is enforced as max(activityDue, NextRefreshAt). type OllamaCloudUsageSnapshot struct { Status string `json:"status"` Data *OllamaCloudUsageData `json:"data,omitempty"` @@ -142,7 +163,7 @@ type ollamaCloudUsageRepository interface { SetOllamaCloudUsageAutoRefresh(context.Context, *Account, bool) error UpdateOllamaCloudUsageSnapshot(context.Context, *Account, *OllamaCloudUsageSnapshot) error DisableOllamaCloudUsageAutoRefresh(context.Context, *Account) error - ListDueOllamaCloudUsageAccounts(context.Context, time.Time, int) ([]Account, error) + ListDueOllamaCloudUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]Account, error) } // GetOllamaCloudUsageSettings returns fail-safe defaults when the setting is absent. @@ -168,6 +189,9 @@ func (s *SettingService) GetOllamaCloudUsageSettings(ctx context.Context) (*Olla if settings.IntervalMinutes == 0 { settings.IntervalMinutes = defaults.IntervalMinutes } + if settings.DebounceMinutes == 0 { + settings.DebounceMinutes = defaults.DebounceMinutes + } normalizeOllamaCloudUsageSettings(&settings) return &settings, nil } @@ -179,12 +203,31 @@ func (s *SettingService) SetOllamaCloudUsageSettings(ctx context.Context, settin if settings == nil { return infraerrors.BadRequest("INVALID_OLLAMA_CLOUD_USAGE_SETTINGS", "settings cannot be nil") } + if settings.DebounceMinutes == 0 { + // Legacy clients that omit debounce_minutes keep the fail-safe default. + settings.DebounceMinutes = ollamaCloudUsageDefaultDebounceMinutes + } if settings.IntervalMinutes < ollamaCloudUsageMinIntervalMinutes || settings.IntervalMinutes > ollamaCloudUsageMaxIntervalMinutes { return infraerrors.BadRequest( "INVALID_OLLAMA_CLOUD_USAGE_INTERVAL", fmt.Sprintf("interval_minutes must be between %d and %d", ollamaCloudUsageMinIntervalMinutes, ollamaCloudUsageMaxIntervalMinutes), ) } + if settings.DebounceMinutes < ollamaCloudUsageMinDebounceMinutes || settings.DebounceMinutes > ollamaCloudUsageMaxDebounceMinutes { + return infraerrors.BadRequest( + "INVALID_OLLAMA_CLOUD_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes must be between %d and %d", ollamaCloudUsageMinDebounceMinutes, ollamaCloudUsageMaxDebounceMinutes), + ) + } + // The due time is min(lastUsed+debounce, fetchedAt+maxWait). Once the debounce + // reaches the max wait the debounce term can never win, so the knob would be + // silently inert instead of doing what the operator asked for. + if settings.DebounceMinutes >= settings.IntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OLLAMA_CLOUD_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes (%d) must be less than interval_minutes (%d)", settings.DebounceMinutes, settings.IntervalMinutes), + ) + } normalizeOllamaCloudUsageSettings(settings) data, err := json.Marshal(settings) if err != nil { @@ -194,7 +237,11 @@ func (s *SettingService) SetOllamaCloudUsageSettings(ctx context.Context, settin } func defaultOllamaCloudUsageSettings() *OllamaCloudUsageSettings { - return &OllamaCloudUsageSettings{Enabled: false, IntervalMinutes: ollamaCloudUsageDefaultIntervalMinutes} + return &OllamaCloudUsageSettings{ + Enabled: false, + IntervalMinutes: ollamaCloudUsageDefaultIntervalMinutes, + DebounceMinutes: ollamaCloudUsageDefaultDebounceMinutes, + } } func normalizeOllamaCloudUsageSettings(settings *OllamaCloudUsageSettings) { @@ -204,6 +251,125 @@ func normalizeOllamaCloudUsageSettings(settings *OllamaCloudUsageSettings) { if settings.IntervalMinutes > ollamaCloudUsageMaxIntervalMinutes { settings.IntervalMinutes = ollamaCloudUsageMaxIntervalMinutes } + if settings.DebounceMinutes <= 0 { + settings.DebounceMinutes = ollamaCloudUsageDefaultDebounceMinutes + } + if settings.DebounceMinutes < ollamaCloudUsageMinDebounceMinutes { + settings.DebounceMinutes = ollamaCloudUsageMinDebounceMinutes + } + if settings.DebounceMinutes > ollamaCloudUsageMaxDebounceMinutes { + settings.DebounceMinutes = ollamaCloudUsageMaxDebounceMinutes + } +} + +func ollamaCloudUsageDurations(settings *OllamaCloudUsageSettings) (debounce, maxWait time.Duration) { + normalized := defaultOllamaCloudUsageSettings() + if settings != nil { + *normalized = *settings + } + normalizeOllamaCloudUsageSettings(normalized) + return time.Duration(normalized.DebounceMinutes) * time.Minute, + time.Duration(normalized.IntervalMinutes) * time.Minute +} + +// ollamaCloudUsageIsAutoRefreshDue decides whether a configured auto-refresh +// group should fetch now. groupLastUsedAt must be MAX(last_used_at) across the +// exact api_key group so shared multi-platform accounts do not miss activity. +// +// Success: a request must be newer than fetched_at; dueAt = min(lastUsed+debounce, fetchedAt+maxWait). +// Failure: a request must be newer than last_attempt_at; activity due uses the same min formula, +// then dueAt = max(activityDue, next_refresh_at) so Retry-After / exponential backoff win. +// Missing or invalid snapshots fail open to a first fetch. +func ollamaCloudUsageIsAutoRefreshDue( + snapshot *OllamaCloudUsageSnapshot, + groupLastUsedAt *time.Time, + now time.Time, + debounce, maxWait time.Duration, +) bool { + dueAt, ok := ollamaCloudUsageAutoRefreshDueAt(snapshot, groupLastUsedAt, debounce, maxWait) + if !ok { + return false + } + return !now.Before(dueAt) +} + +func ollamaCloudUsageAutoRefreshDueAt( + snapshot *OllamaCloudUsageSnapshot, + groupLastUsedAt *time.Time, + debounce, maxWait time.Duration, +) (time.Time, bool) { + if debounce <= 0 { + debounce = time.Duration(ollamaCloudUsageDefaultDebounceMinutes) * time.Minute + } + if maxWait <= 0 { + maxWait = time.Duration(ollamaCloudUsageDefaultIntervalMinutes) * time.Minute + } + if snapshot == nil { + return time.Time{}, true + } + switch snapshot.Status { + case OllamaCloudUsageStatusOK: + if snapshot.FetchedAt == nil || snapshot.FetchedAt.IsZero() { + return time.Time{}, true + } + fetchedAt := snapshot.FetchedAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(fetchedAt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + dueAt := minTime(lastUsed.Add(debounce), fetchedAt.Add(maxWait)) + // Keep the pre-existing hard floor between successful fetches. The success + // path no longer consults next_refresh_at, which is where + // nextOllamaCloudUsageDelay used to apply ollamaCloudUsageMinIntervalMinutes; + // without this, request traffic spaced slightly wider than the debounce + // drives the group's outbound rate far above the previous minimum. + if floor := fetchedAt.Add(OllamaCloudUsageMinFetchInterval); dueAt.Before(floor) { + return floor, true + } + return dueAt, true + case OllamaCloudUsageStatusFailed, OllamaCloudUsageStatusUnauthorized: + if snapshot.LastAttemptAt.IsZero() { + return time.Time{}, true + } + lastAttempt := snapshot.LastAttemptAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(lastAttempt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + activityDue := minTime(lastUsed.Add(debounce), lastAttempt.Add(maxWait)) + if !snapshot.NextRefreshAt.IsZero() && snapshot.NextRefreshAt.UTC().After(activityDue) { + return snapshot.NextRefreshAt.UTC(), true + } + return activityDue, true + default: + return time.Time{}, true + } +} + +// maxOllamaCloudUsageGroupLastUsed returns the newest last_used_at among group members. +func maxOllamaCloudUsageGroupLastUsed(accounts []Account) *time.Time { + var latest *time.Time + for i := range accounts { + candidate := accounts[i].LastUsedAt + if candidate == nil || candidate.IsZero() { + continue + } + if latest == nil || candidate.After(*latest) { + ts := candidate.UTC() + latest = &ts + } + } + return latest +} + +// scheduleOllamaCloudUsageActivity records that an Ollama Cloud API-key account +// actually attempted an upstream model request (including 429/5xx/transport errors). +// Local auth/validation failures must not call this. DeferredService dedupes writes. +func scheduleOllamaCloudUsageActivity(deferred *DeferredService, account *Account) { + if deferred == nil || account == nil || !IsOllamaCloudUsageAccount(account) { + return + } + deferred.ScheduleLastUsedUpdate(account.ID) } // OllamaCloudUsageService refreshes the official settings HTML without affecting routing state. @@ -531,7 +697,7 @@ func (s *OllamaCloudUsageService) Refresh(ctx context.Context, accountID int64) if err != nil { return nil, err } - if _, err := s.refreshAccount(ctx, accountID, settings.IntervalMinutes, false); err != nil { + if _, err := s.refreshAccount(ctx, accountID, settings, false); err != nil { return nil, err } return s.GetState(ctx, accountID) @@ -561,7 +727,8 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { return ErrOllamaCloudUsageUnavailable } now := s.currentTime() - accounts, err := writer.ListDueOllamaCloudUsageAccounts(ctx, now, ollamaCloudUsageMaxPerCycle) + debounce, maxWait := ollamaCloudUsageDurations(settings) + accounts, err := writer.ListDueOllamaCloudUsageAccounts(ctx, now, debounce, maxWait, ollamaCloudUsageMaxPerCycle) if err != nil { return fmt.Errorf("list due Ollama Cloud usage accounts: %w", err) } @@ -577,13 +744,15 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { continue } seenGroups[fingerprint] = struct{}{} - if snapshot := decodeOllamaCloudUsageSnapshot(account.Extra); snapshot != nil && now.Before(snapshot.NextRefreshAt) { + snapshot := decodeOllamaCloudUsageSnapshot(account.Extra) + // ListDue stamps Account.LastUsedAt with the api_key group MAX(last_used_at). + if !ollamaCloudUsageIsAutoRefreshDue(snapshot, account.LastUsedAt, now, debounce, maxWait) { continue } accountID := account.ID expected := account group.Go(func() error { - if _, refreshErr := s.refreshAccount(ctx, accountID, settings.IntervalMinutes, true); refreshErr != nil { + if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { if errors.Is(refreshErr, ErrOllamaCloudUsageIdentityChanged) { if disableErr := writer.DisableOllamaCloudUsageAutoRefresh(ctx, &expected); disableErr != nil { logger.LegacyPrintf("service.ollama_cloud_usage", "disable_auto_refresh_failed: account_id=%d err=%v", accountID, disableErr) @@ -598,10 +767,15 @@ func (s *OllamaCloudUsageService) RunDue(ctx context.Context) error { return group.Wait() } -func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID int64, intervalMinutes int, requireEnabled bool) (*OllamaCloudUsageSnapshot, error) { +func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID int64, settings *OllamaCloudUsageSettings, requireEnabled bool) (*OllamaCloudUsageSnapshot, error) { if s == nil || s.accountRepo == nil { return nil, ErrOllamaCloudUsageUnavailable } + if settings == nil { + settings = defaultOllamaCloudUsageSettings() + } + intervalMinutes := settings.IntervalMinutes + debounce, maxWait := ollamaCloudUsageDurations(settings) anchor, err := s.accountRepo.GetByID(ctx, accountID) if err != nil { return nil, err @@ -650,7 +824,21 @@ func (s *OllamaCloudUsageService) refreshAccount(ctx context.Context, accountID if !account.IsActive() || !ollamaCloudUsageAutoRefreshEnabled(account) { return nil, nil } - if snapshot := decodeOllamaCloudUsageSnapshot(account.Extra); snapshot != nil && s.currentTime().Before(snapshot.NextRefreshAt) { + groupLastUsed := account.LastUsedAt + if writer, ok := s.accountRepo.(ollamaCloudUsageRepository); ok { + siblings, listErr := writer.ListOllamaCloudUsageGroupAccounts(ctx, []*Account{account}) + if listErr != nil { + // Fall back to this account's own last_used_at. That is a narrower + // activity signal than the group maximum, so the due check may skip a + // refresh it would otherwise have run; surface it rather than + // silently changing the due semantics. + logger.LegacyPrintf("service.ollama_cloud_usage", + "group_last_used_lookup_failed: account_id=%d err=%v", account.ID, listErr) + } else { + groupLastUsed = maxOllamaCloudUsageGroupLastUsed(siblings) + } + } + if !ollamaCloudUsageIsAutoRefreshDue(decodeOllamaCloudUsageSnapshot(account.Extra), groupLastUsed, s.currentTime(), debounce, maxWait) { return nil, nil } } diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index d949ce41fd..d315d7eb41 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -185,7 +185,7 @@ func applyOllamaUsageTestManagedExtra(account, source *Account) { } } -func (r *ollamaUsageTestRepo) ListDueOllamaCloudUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) { +func (r *ollamaUsageTestRepo) ListDueOllamaCloudUsageAccounts(_ context.Context, _ time.Time, _, _ time.Duration, limit int) ([]Account, error) { r.mu.Lock() defer r.mu.Unlock() if len(r.due) > 0 { @@ -313,15 +313,141 @@ func TestOllamaCloudUsageSettingsDefaultOffAndValidation(t *testing.T) { require.NoError(t, err) require.False(t, settings.Enabled) require.Equal(t, 60, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) - err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 14}) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 14, DebounceMinutes: 1}) require.Error(t, err) - err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90}) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 61}) + require.Error(t, err) + // DebounceMinutes=0 (legacy omit) defaults to 1 on write. + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 0}) + require.NoError(t, err) + settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, settings.DebounceMinutes) + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90, DebounceMinutes: 2}) require.NoError(t, err) settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background()) require.NoError(t, err) require.True(t, settings.Enabled) require.Equal(t, 90, settings.IntervalMinutes) + require.Equal(t, 2, settings.DebounceMinutes) + + // debounce >= interval would make the debounce term unreachable in + // min(lastUsed+debounce, fetchedAt+maxWait), silently ignoring the operator's + // setting, so it is rejected rather than accepted and dropped. + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 15, DebounceMinutes: 15}) + require.Error(t, err, "debounce equal to interval must be rejected") + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 15, DebounceMinutes: 60}) + require.Error(t, err, "debounce greater than interval must be rejected") + err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 16, DebounceMinutes: 15}) + require.NoError(t, err, "debounce below interval stays valid") + + // Legacy JSON without debounce_minutes defaults to 1. + repo.values[SettingKeyOllamaCloudUsageSettings] = `{"enabled":true,"interval_minutes":45}` + settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 45, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) +} + +func TestOllamaCloudUsageIsAutoRefreshDue(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + ptr := func(ts time.Time) *time.Time { return &ts } + + require.True(t, ollamaCloudUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due") + require.True(t, ollamaCloudUsageIsAutoRefreshDue(&OllamaCloudUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due") + + okSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(fetched), + LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait), + } + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed") + require.True(t, ollamaCloudUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce") + + // Continuous requests: last used is now, but max-wait from old fetch forces due. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(oldFetched), + LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait), + } + require.True(t, ollamaCloudUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue") + // First request after a very old snapshot is immediately due because fetched+maxWait is past. + require.True(t, ollamaCloudUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate") + + failSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusFailed, FetchedAt: ptr(fetched), + LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute), + } + require.False(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff") + failSnap.NextRefreshAt = now.Add(-time.Second) + require.True(t, ollamaCloudUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request") + + require.True(t, ollamaCloudUsageIsAutoRefreshDue(&OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, LastAttemptAt: now, + }, nil, now, debounce, maxWait), "ok without fetched_at fails open") +} + +// The success path stopped consulting next_refresh_at, which is where +// nextOllamaCloudUsageDelay used to apply the minimum interval. Activity may pull +// a refresh forward only as far as that floor, otherwise request traffic spaced +// just wider than the debounce drives the group's outbound rate far above the +// pre-existing minimum. +func TestOllamaCloudUsageAutoRefreshDueAtHonoursMinFetchInterval(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + ptr := func(ts time.Time) *time.Time { return &ts } + + // Debounce elapsed, but the last successful fetch is inside the floor. + recent := now.Add(-5 * time.Minute) + recentSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(recent), LastAttemptAt: recent, + } + dueAt, ok := ollamaCloudUsageAutoRefreshDueAt(recentSnap, ptr(now.Add(-2*time.Minute)), debounce, maxWait) + require.True(t, ok) + require.Equal(t, recent.Add(OllamaCloudUsageMinFetchInterval), dueAt, + "due time must be clamped to fetched_at + min fetch interval") + require.False(t, ollamaCloudUsageIsAutoRefreshDue(recentSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait), + "debounce alone must not refresh within the min fetch interval") + + // Once the floor has passed the debounce governs again. + atFloor := now.Add(-OllamaCloudUsageMinFetchInterval) + floorSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(atFloor), LastAttemptAt: atFloor, + } + require.True(t, ollamaCloudUsageIsAutoRefreshDue(floorSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait), + "past the floor a quiet debounce window is due") + + // The floor never delays a refresh that max-wait has already forced. + stale := now.Add(-2 * time.Hour) + staleSnap := &OllamaCloudUsageSnapshot{ + Status: OllamaCloudUsageStatusOK, FetchedAt: ptr(stale), LastAttemptAt: stale, + } + require.True(t, ollamaCloudUsageIsAutoRefreshDue(staleSnap, ptr(now), now, debounce, maxWait), + "max-wait still forces due on a stale snapshot") +} + +func TestScheduleOllamaCloudUsageActivityOnlyForOllama(t *testing.T) { + deferred := NewDeferredService(nil, nil, time.Second) + ollama := ollamaUsageAccount(1) + other := ollamaUsageAccount(2) + other.Credentials["base_url"] = "https://api.openai.com" + + scheduleOllamaCloudUsageActivity(deferred, ollama) + scheduleOllamaCloudUsageActivity(deferred, other) + scheduleOllamaCloudUsageActivity(nil, ollama) + + _, ok := deferred.lastUsedUpdates.Load(int64(1)) + require.True(t, ok) + _, ok = deferred.lastUsedUpdates.Load(int64(2)) + require.False(t, ok) } func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 991a1e5170..ace8366a0e 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -51,6 +51,10 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(statusCode, responseBody) { return false } + // Any non-2xx upstream HTTP response means the model request was actually sent. + if s != nil { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() @@ -91,7 +95,12 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if shouldDisable && !modelTempMatched { s.BlockAccountScheduling(account, time.Time{}, "upstream_disable") } - if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) { + // Pool-mode retryable upstream errors are already bounded by the request-local + // same-account retry budget. Recording the generic account+model transient + // cooldown here would block the next approved retry before that budget is used. + poolModeRetryable := account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) + if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && + shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) && !poolModeRetryable { model := "" if len(canonicalModel) > 0 { model = canonicalModel[0] diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index ae4bfa91dc..09d4662468 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -142,6 +142,89 @@ func TestOpenAIPoolModeTempRule_StopsSameAccountRetryAndIsolatesBlockToModel(t * require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) } +func TestOpenAIPoolModeRetryable5xx_DoesNotCreateModelTransientBlock(t *testing.T) { + repo := &errorPolicyRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + gateway := &OpenAIGatewayService{rateLimitService: rateLimitService} + account := &Account{ + ID: 47, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "pool_mode_retry_status_codes": []any{float64(524)}, + }, + } + + for i := 0; i < 2; i++ { + shouldDisable := gateway.handleOpenAIAccountUpstreamError( + context.Background(), + account, + 524, + http.Header{}, + []byte(`{"error":{"message":"upstream timeout"}}`), + "gpt-5.4", + ) + require.False(t, shouldDisable) + } + + require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) +} + +func TestOpenAIPoolModeNonRetryable5xx_StillCreatesModelTransientBlock(t *testing.T) { + repo := &errorPolicyRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + gateway := &OpenAIGatewayService{rateLimitService: rateLimitService} + account := &Account{ + ID: 48, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "pool_mode_retry_status_codes": []any{float64(http.StatusGatewayTimeout)}, + }, + } + + for i := 0; i < 2; i++ { + shouldDisable := gateway.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusServiceUnavailable, + http.Header{}, + []byte(`{"error":{"message":"upstream unavailable"}}`), + "gpt-5.4", + ) + require.False(t, shouldDisable) + } + + require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) +} + +func TestOpenAINonPoolAPIKey5xx_StillCreatesModelTransientBlock(t *testing.T) { + repo := &errorPolicyRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + gateway := &OpenAIGatewayService{rateLimitService: rateLimitService} + account := &Account{ + ID: 49, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + for i := 0; i < 2; i++ { + shouldDisable := gateway.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusGatewayTimeout, + http.Header{}, + []byte(`{"error":{"message":"upstream timeout"}}`), + "gpt-5.4", + ) + require.False(t, shouldDisable) + } + + require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) +} + func TestOpenAIModelNotFound_DoesNotRuntimeBlockWholeAccount(t *testing.T) { repo := &modelNotFoundAccountRepoStub{} svc := &OpenAIGatewayService{ diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 2d6766af4f..c89fd038d0 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1494,25 +1494,9 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an if !opts.PreserveReferences { ensureCopy() delete(newItem, "id") - } else if isCodexToolCallInputType(typ) { - // 续链模式下保留 id 以维持上下文引用,但 function_call 等 - // call-input 类 item 的 id 必须以 "fc" 开头(上游校验 - // "Expected an ID that begins with 'fc'")。item_* 形式的 id - // 来自客户端回放,需要删除。 - // 注意:function_call_output 等 output 类的 id 无此约束,不动。 - if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "fc") { - ensureCopy() - delete(newItem, "id") - } - } else if typ == "message" { - // 同理,message 类 item 的 id 必须以 "msg" 开头(上游校验 - // "Expected an ID that begins with 'msg'")。item_* 形式的 id - // 来自客户端回放,需要删除。 - // 注意:不改写成 msg_*,改写出的 id 未必对应真实的上游对象。 - if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "msg") { - ensureCopy() - delete(newItem, "id") - } + } else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) { + ensureCopy() + delete(newItem, "id") } filtered = append(filtered, newItem) diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go new file mode 100644 index 0000000000..b7af70ec75 --- /dev/null +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "runtime" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`, + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":false, + "input":[ + {"type":"message","id":"item_bad_message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}, + {"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"}, + {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"}, + {"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"}, + {"type":"web_search_call","id":"item_unconstrained"} + ] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + + forwarded := upstream.lastBody + require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists()) + require.Equal(t, "hello", gjson.GetBytes(forwarded, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(forwarded, "input.1.id").Exists()) + require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.1.call_id").String()) + require.Equal(t, "exec_command", gjson.GetBytes(forwarded, "input.1.name").String()) + require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String()) + require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String()) + require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String()) + require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.4.id").String()) + require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.4.call_id").String()) + require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String()) +} + +func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) { + makeBody := func(itemCount int) []byte { + items := make([]string, itemCount) + for i := range items { + items[i] = fmt.Sprintf(`{"type":"message","id":"item_%d","role":"user","content":[{"type":"input_text","text":"hello"}]}`, i) + } + return []byte(`{"model":"gpt-5.6-sol","input":[` + strings.Join(items, ",") + `]}`) + } + allocatedBytes := func(body []byte) uint64 { + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + runtime.ReadMemStats(&after) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, sanitized) + return after.TotalAlloc - before.TotalAlloc + } + + smallAllocated := allocatedBytes(makeBody(20)) + largeAllocated := allocatedBytes(makeBody(200)) + require.Less(t, largeAllocated, smallAllocated*30, + "10x more input items must not cause quadratic whole-body allocation growth") +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7a42a445ef..b3963535b1 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -72,6 +72,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return nil, err } } + if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) { + body, err = stripOpenAIResponsesInputNamespaces(body) + if err != nil { + setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", "message": err.Error(), "param": "input", + }}) + return nil, err + } + } originalBody := body requestView := newOpenAIRequestView(body) @@ -85,6 +95,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } + if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { + sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body) + if sanitizeErr != nil { + return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr) + } + if changed { + body = sanitizedBody + originalBody = sanitizedBody + requestView = newOpenAIRequestView(sanitizedBody) + reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel = reqModel + } + } compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index d887ff73fc..fdfd0bf321 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -663,7 +663,13 @@ var grokResponsesSupportedToolTypes = map[string]struct{}{ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { tools := gjson.GetBytes(body, "tools") - if !tools.Exists() || !tools.IsArray() { + if !tools.Exists() { + if gjson.GetBytes(body, "tool_choice").Exists() { + return sjson.DeleteBytes(body, "tool_choice") + } + return body, nil + } + if !tools.IsArray() { return body, nil } @@ -1363,7 +1369,7 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex case http.StatusTooManyRequests: // updateGrokUsageSnapshot installs both runtime and durable rate-limit state. default: - if statusCode >= 500 { + if statusCode >= 500 && !account.IsPoolMode() { s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error") } } diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index ca798d8141..170528ce76 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -125,6 +125,11 @@ func isGrokRequestContext(c *gin.Context) bool { if c == nil { return false } + if c.Request != nil { + if platform, ok := ResolvedTargetPlatformFromContext(c.Request.Context()); ok { + return platform == PlatformGrok + } + } v, exists := c.Get("api_key") if !exists { return false diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 3a8e347332..d13cfa19fc 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -209,6 +209,60 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } +func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantTools bool + wantToolChoice bool + }{ + { + name: "missing tools with string tool choice", + body: `{"input":"hello","tool_choice":"auto"}`, + }, + { + name: "missing tools with object tool choice", + body: `{"input":"hello","tool_choice":{"type":"function","name":"lookup"}}`, + }, + { + name: "empty tools", + body: `{"input":"hello","tools":[],"tool_choice":"auto"}`, + wantTools: true, + }, + { + name: "all tools unsupported", + body: `{"input":"hello","tools":[{"type":"namespace","name":"client_tools"}],"tool_choice":"auto"}`, + }, + { + name: "supported tool", + body: `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto"}`, + wantTools: true, + wantToolChoice: true, + }, + { + name: "malformed non-array tools remain untouched", + body: `{"input":"hello","tools":{"type":"function","name":"lookup"},"tool_choice":"auto"}`, + wantTools: true, + wantToolChoice: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patched, err := sanitizeGrokResponsesTools([]byte(tt.body)) + require.NoError(t, err) + require.True(t, json.Valid(patched)) + require.Equal(t, tt.wantTools, gjson.GetBytes(patched, "tools").Exists()) + require.Equal(t, tt.wantToolChoice, gjson.GetBytes(patched, "tool_choice").Exists()) + if tt.wantToolChoice { + require.Equal(t, "auto", gjson.GetBytes(patched, "tool_choice").String()) + } + }) + } +} + func TestPatchGrokResponsesBodyPromotesCodexAdditionalTools(t *testing.T) { t.Parallel() @@ -2475,6 +2529,43 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test } } +func TestHandleGrokAccountUpstreamError5xxRespectsPoolMode(t *testing.T) { + t.Run("pool mode keeps scheduling state", func(t *testing.T) { + account := &Account{ + ID: 611, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + }, + } + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadGateway, nil, nil) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Zero(t, repo.tempUnschedCalls) + require.Nil(t, account.TempUnschedulableUntil) + require.Empty(t, account.TempUnschedulableReason) + }) + + t.Run("non-pool mode keeps two minute cooldown", func(t *testing.T) { + account := &Account{ID: 612, Platform: PlatformGrok, Type: AccountTypeAPIKey} + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + before := time.Now() + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadGateway, nil, nil) + + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, account.ID, repo.lastTempUnschedID) + require.Equal(t, "grok upstream temporary error", repo.lastTempUnschedReason) + require.WithinDuration(t, before.Add(2*time.Minute), repo.lastTempUnschedUntil, time.Second) + }) +} + func TestHandleGrokAccountUpstreamError429SetsRateLimitedFromRetryAfter(t *testing.T) { account := &Account{ID: 61, Platform: PlatformGrok, Type: AccountTypeOAuth} repo := &grokQuotaAccountRepo{} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index a895b2ebf5..01316cb58a 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -26,6 +26,15 @@ const ( codeBuddyConversationHeader = "X-Conversation-ID" ) +var explicitOpenAIHeaderSessionNames = []string{ + "session_id", + "conversation_id", + openCodeSessionAffinityHeader, + openCodeSessionIDHeader, + openCodeNativeSessionHeader, + codeBuddyConversationHeader, +} + // explicitOpenAIHeaderSessionID resolves stable conversation identifiers sent // by OpenAI-compatible clients. Keep this list limited to session-scoped // fields: request/message IDs rotate every turn and would defeat sticky routing @@ -35,14 +44,7 @@ func explicitOpenAIHeaderSessionID(c *gin.Context) string { return "" } - for _, header := range []string{ - "session_id", - "conversation_id", - openCodeSessionAffinityHeader, - openCodeSessionIDHeader, - openCodeNativeSessionHeader, - codeBuddyConversationHeader, - } { + for _, header := range explicitOpenAIHeaderSessionNames { if sessionID := strings.TrimSpace(c.GetHeader(header)); sessionID != "" { return sessionID } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 5cb43f7baa..c06a989908 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -19,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" @@ -410,6 +411,8 @@ type OpenAIGatewayService struct { balanceNotifyService *BalanceNotifyService settingService *SettingService userPlatformQuotaRepo UserPlatformQuotaRepository + liveAttestation liveattestation.Provider + liveAttestationCipher SecretEncryptor openaiWSPoolOnce sync.Once openaiWSStateStoreOnce sync.Once @@ -499,6 +502,8 @@ func NewOpenAIGatewayService( balanceNotifyService: balanceNotifyService, settingService: settingService, userPlatformQuotaRepo: userPlatformQuotaRepo, + liveAttestation: liveattestation.NewProvider(), + liveAttestationCipher: newLiveAttestationCipher(cfg), responseHeaderFilter: compileResponseHeaderFilter(cfg), codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval), openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax), diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 773b31d625..16429f7402 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -29,6 +29,7 @@ type OpenAIRecordUsageInput struct { UpstreamEndpoint string UserAgent string // 请求的 User-Agent IPAddress string // 请求的客户端 IP 地址 + SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联 RequestPayloadHash string APIKeyService APIKeyQuotaUpdater QuotaPlatform string // user×platform quota platform resolved by the handler before async billing. @@ -55,6 +56,7 @@ type CyberPolicyUsageInput struct { UpstreamEndpoint string UserAgent string IPAddress string + SessionID string RequestPayloadHash string APIKeyService APIKeyQuotaUpdater ChannelUsageFields @@ -89,6 +91,7 @@ func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in UpstreamEndpoint: in.UpstreamEndpoint, UserAgent: in.UserAgent, IPAddress: in.IPAddress, + SessionID: in.SessionID, RequestPayloadHash: in.RequestPayloadHash, APIKeyService: in.APIKeyService, ChannelUsageFields: in.ChannelUsageFields, @@ -333,6 +336,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec usageLog.IPAddress = &input.IPAddress } + // 添加 SessionID(客户端显式会话标识;缺失/无效时保持 nil) + usageLog.SessionID = optionalTrimmedStringPtr(input.SessionID) + if apiKey.GroupID != nil { usageLog.GroupID = apiKey.GroupID } diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go new file mode 100644 index 0000000000..413d288aa6 --- /dev/null +++ b/backend/internal/service/openai_live.go @@ -0,0 +1,792 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + coderws "github.com/coder/websocket" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +const ( + defaultLiveMaxSessionDuration = time.Hour + liveLeaseRefreshInterval = 20 * time.Second + liveRedisOperationTimeout = 3 * time.Second + liveClosedRecordTTL = 24 * time.Hour + liveObserverPollInterval = 250 * time.Millisecond + liveUpstreamBodyLimit = 2 << 20 +) + +var ( + chatGPTLiveCallsURL = "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas" + chatGPTLiveSidebandBaseURL = "wss://chatgpt.com/backend-api/codex" +) + +type liveFrameConn interface { + ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) + WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error + Close() error +} + +func liveSidebandReadError(err error) error { + if coderws.CloseStatus(err) == coderws.StatusNormalClosure { + return ErrLiveCallNotFound + } + return err +} + +func hashLiveCallID(callID string) string { + sum := sha256.Sum256([]byte(callID)) + return hex.EncodeToString(sum[:]) +} + +func liveGroupID(groupID *int64) int64 { + if groupID == nil { + return 0 + } + return *groupID +} + +func liveOptionalID(value int64) *int64 { + if value <= 0 { + return nil + } + result := value + return &result +} + +func (s *OpenAIGatewayService) liveStore() (LiveCallStore, error) { + if s == nil || s.cache == nil { + return nil, ErrLiveUnavailable + } + store, ok := s.cache.(LiveCallStore) + if !ok { + return nil, ErrLiveUnavailable + } + return store, nil +} + +func (s *OpenAIGatewayService) liveConcurrencyCache() (LiveConcurrencyCache, error) { + if s == nil || s.concurrencyService == nil || s.concurrencyService.cache == nil { + return nil, ErrLiveUnavailable + } + cache, ok := s.concurrencyService.cache.(LiveConcurrencyCache) + if !ok { + return nil, ErrLiveUnavailable + } + return cache, nil +} + +func (s *OpenAIGatewayService) liveMaxSessionDuration() time.Duration { + if s != nil && s.cfg != nil && s.cfg.Gateway.Live.MaxSessionDurationSeconds > 0 { + return time.Duration(s.cfg.Gateway.Live.MaxSessionDurationSeconds) * time.Second + } + return defaultLiveMaxSessionDuration +} + +func ValidateLiveCallRequest(request *LiveCallRequest) error { + if request == nil || strings.TrimSpace(request.SDP) == "" { + return errors.New("sdp is required") + } + if len(request.Session) == 0 || !json.Valid(request.Session) { + return errors.New("session must be valid JSON") + } + var sessionObject map[string]json.RawMessage + if err := json.Unmarshal(request.Session, &sessionObject); err != nil { + return errors.New("session must be a JSON object") + } + if sessionObject == nil { + return errors.New("session must be a JSON object") + } + return nil +} + +// CreateLiveCall 创建 Frameless 会话。调用方须在调用期间持有普通用户槽位; +// 调度器持有的普通账号槽位会被同一个 Live 租约原子接替。 +func (s *OpenAIGatewayService) CreateLiveCall( + ctx context.Context, + request *LiveCallRequest, + identity LiveCallIdentity, + userMaxConcurrency int, +) (*LiveCallCreated, error) { + if err := ValidateLiveCallRequest(request); err != nil { + return nil, err + } + store, err := s.liveStore() + if err != nil { + return nil, err + } + liveCache, err := s.liveConcurrencyCache() + if err != nil { + return nil, err + } + attestation, attestationCiphertext, err := s.prepareLiveAttestation(ctx) + if err != nil { + return nil, err + } + + excluded := make(map[int64]struct{}) + var lastErr error + for attempt := 0; attempt <= 3; attempt++ { + selection, _, selectErr := s.SelectAccountWithSchedulerForCapability( + ctx, + identity.GroupID, + "", + uuid.NewString(), + "", + excluded, + OpenAIUpstreamTransportHTTPSSE, + OpenAIEndpointCapabilityLive, + false, + false, + false, + ) + if selectErr != nil { + if lastErr != nil { + return nil, lastErr + } + return nil, selectErr + } + if selection == nil || selection.Account == nil || !selection.Acquired { + if selection != nil && selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + return nil, ErrLiveConcurrencyFull + } + + account := selection.Account + leaseID := generateRequestID() + acquired, acquireErr := liveCache.AcquireLiveLease( + ctx, + account.ID, + account.Concurrency, + identity.UserID, + userMaxConcurrency, + identity.APIKeyID, + leaseID, + true, + ) + if acquireErr != nil || !acquired { + selection.ReleaseFunc() + if acquireErr != nil { + return nil, acquireErr + } + return nil, ErrLiveConcurrencyFull + } + + created, createErr := s.createUpstreamLiveCall(ctx, account, request, attestation) + selection.ReleaseFunc() + if createErr != nil { + s.releaseLiveLease(account.ID, identity.UserID, identity.APIKeyID, leaseID) + if !s.shouldFailoverLiveCreateError(createErr) { + return nil, createErr + } + excluded[account.ID] = struct{}{} + lastErr = createErr + continue + } + + now := time.Now() + model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String()) + if model == "" { + model = "gpt-live" + } + record := &LiveCallRecord{ + CallID: created.CallID, + CallHash: hashLiveCallID(created.CallID), + AccountID: account.ID, + APIKeyID: identity.APIKeyID, + UserID: identity.UserID, + GroupID: liveGroupID(identity.GroupID), + SubscriptionID: liveGroupID(identity.SubscriptionID), + LeaseID: leaseID, + Model: model, + CreatedAt: now, + ExpiresAt: now.Add(s.liveMaxSessionDuration()), + Controller: LiveControllerPending, + UserAgent: identity.UserAgent, + IPAddress: identity.IPAddress, + InboundEndpoint: identity.InboundEndpoint, + AttestationCiphertext: attestationCiphertext, + } + mappingTTL := s.liveMaxSessionDuration() + 5*time.Minute + if saveErr := store.SaveLiveCall(ctx, record, mappingTTL); saveErr != nil { + s.releaseLiveLease(account.ID, identity.UserID, identity.APIKeyID, leaseID) + return nil, fmt.Errorf("save live call mapping: %w", saveErr) + } + created.Account = account + go s.observeLiveCall(record.CallHash) + return created, nil + } + if lastErr != nil { + return nil, lastErr + } + return nil, ErrLiveUnavailable +} + +func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(err error) bool { + var upstreamErr *UpstreamFailoverError + if !errors.As(err, &upstreamErr) { + // 凭证读取和网络传输错误都可能只影响当前账号或代理。 + return true + } + return s.shouldFailoverOpenAIUpstreamResponse( + upstreamErr.StatusCode, + "", + upstreamErr.ResponseBody, + ) +} + +func (s *OpenAIGatewayService) createUpstreamLiveCall( + ctx context.Context, + account *Account, + request *LiveCallRequest, + attestation string, +) (*LiveCallCreated, error) { + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + logLiveCreateStageFailure(ctx, account.ID, "access_token", err) + return nil, err + } + body, err := json.Marshal(struct { + SDP string `json:"sdp"` + Session json.RawMessage `json:"session"` + }{ + SDP: request.SDP, + Session: request.Session, + }) + if err != nil { + return nil, err + } + reqCtx := WithHTTPUpstreamRedirectsDisabled(WithHTTPUpstreamProfile(ctx, HTTPUpstreamProfileOpenAI)) + upstreamReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, chatGPTLiveCallsURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token) + if err != nil { + logLiveCreateStageFailure(ctx, account.ID, "authentication_headers", err) + return nil, err + } + for key, values := range authHeaders { + for _, value := range values { + upstreamReq.Header.Add(key, value) + } + } + upstreamReq.Host = "chatgpt.com" + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, upstreamReq.Header, account); err != nil { + logLiveCreateStageFailure(ctx, account.ID, "account_headers", err) + return nil, err + } + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Accept", "application/sdp") + upstreamReq.Header.Set(liveAttestationHeader, attestation) + applyLiveUpstreamIdentityHeaders(upstreamReq.Header) + + resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency) + if err != nil { + logLiveCreateStageFailure(ctx, account.ID, "upstream_transport", err) + return nil, err + } + defer func() { _ = resp.Body.Close() }() + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, liveUpstreamBodyLimit+1)) + if readErr != nil { + return nil, readErr + } + if len(responseBody) > liveUpstreamBodyLimit { + return nil, errors.New("live upstream response is too large") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logLiveUpstreamFailure(ctx, account.ID, resp.StatusCode, resp.Header, responseBody) + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: responseBody, + ResponseHeaders: resp.Header.Clone(), + } + } + callID, err := liveCallIDFromLocation(resp.Header.Get("Location")) + if err != nil { + return nil, err + } + return &LiveCallCreated{ + SDP: responseBody, + CallID: callID, + Location: resp.Header.Get("Location"), + }, nil +} + +func logLiveCreateStageFailure(ctx context.Context, accountID int64, stage string, err error) { + logger.FromContext(ctx).Warn( + "OpenAI Live 创建阶段失败", + zap.Int64("account_id", accountID), + zap.String("stage", stage), + zap.String("error_type", fmt.Sprintf("%T", err)), + ) +} + +func logLiveUpstreamFailure( + ctx context.Context, + accountID int64, + statusCode int, + headers http.Header, + body []byte, +) { + errorType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String()) + errorCode := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()) + errorMessage := strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) + if errorType == "" { + errorType = strings.TrimSpace(gjson.GetBytes(body, "type").String()) + } + if errorCode == "" { + errorCode = strings.TrimSpace(gjson.GetBytes(body, "code").String()) + } + if errorMessage == "" { + errorMessage = strings.TrimSpace(gjson.GetBytes(body, "message").String()) + } + if errorMessage == "" { + errorMessage = strings.TrimSpace(gjson.GetBytes(body, "detail").String()) + } + + logger.FromContext(ctx).Warn( + "OpenAI Live 上游拒绝请求", + zap.Int64("account_id", accountID), + zap.Int("upstream_status_code", statusCode), + zap.String("upstream_error_type", truncateOpenAIWSLogValue(errorType, 120)), + zap.String("upstream_error_code", truncateOpenAIWSLogValue(errorCode, 120)), + zap.String("upstream_error_message", truncateOpenAIWSLogValue(errorMessage, 300)), + zap.String("upstream_content_type", truncateOpenAIWSLogValue(headers.Get("Content-Type"), 120)), + zap.String("upstream_server", truncateOpenAIWSLogValue(headers.Get("Server"), 120)), + zap.String("upstream_cf_mitigated", truncateOpenAIWSLogValue(headers.Get("Cf-Mitigated"), 120)), + zap.String("upstream_cf_ray", truncateOpenAIWSLogValue(headers.Get("Cf-Ray"), 120)), + zap.String("upstream_request_id", truncateOpenAIWSLogValue(headers.Get("X-Request-Id"), 120)), + ) +} + +func liveCallIDFromLocation(location string) (string, error) { + location = strings.TrimSpace(location) + if location == "" { + return "", errors.New("live upstream response has no Location") + } + parsed, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("parse live Location: %w", err) + } + callID := strings.TrimSpace(path.Base(strings.TrimSuffix(parsed.Path, "/"))) + if callID == "" || callID == "." || callID == "codex" { + return "", errors.New("live upstream Location has no call id") + } + return callID, nil +} + +func applyLiveUpstreamIdentityHeaders(headers http.Header) { + headers.Set("OpenAI-Alpha", "quicksilver=v2") + ensureCodexIdentityHeaders(headers) + enforceCodexIdentityHeaders(headers) + if strings.TrimSpace(headers.Get("session-id")) == "" { + headers.Set("session-id", uuid.NewString()) + } + if strings.TrimSpace(headers.Get("thread-id")) == "" { + headers.Set("thread-id", uuid.NewString()) + } + // Realtime/Live 不使用 Responses 的实验头。 + headers.Del("OpenAI-Beta") +} + +func (s *OpenAIGatewayService) liveSidebandHeaders( + ctx context.Context, + account *Account, + record *LiveCallRecord, +) (http.Header, error) { + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + headers, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token) + if err != nil { + return nil, err + } + if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { + return nil, err + } + attestation, err := s.decryptLiveAttestation(record) + if err != nil { + return nil, err + } + headers.Set(liveAttestationHeader, attestation) + applyLiveUpstreamIdentityHeaders(headers) + return headers, nil +} + +func (s *OpenAIGatewayService) dialLiveSideband(ctx context.Context, record *LiveCallRecord) (liveFrameConn, error) { + account, err := s.accountRepo.GetByID(ctx, record.AccountID) + if err != nil { + return nil, err + } + if account == nil || !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive) { + return nil, ErrLiveUnavailable + } + headers, err := s.liveSidebandHeaders(ctx, account, record) + if err != nil { + return nil, err + } + target := strings.TrimRight(chatGPTLiveSidebandBaseURL, "/") + "/" + url.PathEscape(record.CallID) + conn, status, _, err := s.getOpenAIWSPassthroughDialer().Dial(ctx, target, headers, resolveAccountProxyURL(account)) + if err != nil { + return nil, fmt.Errorf("dial live sideband (status %d): %w", status, err) + } + raw, ok := conn.(liveFrameConn) + if !ok { + _ = conn.Close() + return nil, errors.New("live sideband transport does not support raw frames") + } + return raw, nil +} + +func (s *OpenAIGatewayService) GetLiveCallForIdentity( + ctx context.Context, + callID string, + identity LiveCallIdentity, +) (*LiveCallRecord, error) { + store, err := s.liveStore() + if err != nil { + return nil, err + } + record, err := store.GetLiveCall(ctx, hashLiveCallID(callID)) + if err != nil { + return nil, err + } + if record.CallID != callID || + record.APIKeyID != identity.APIKeyID || + record.UserID != identity.UserID || + record.GroupID != liveGroupID(identity.GroupID) { + return nil, ErrLiveIdentityMismatch + } + if record.Controller == LiveControllerClosed { + return nil, ErrLiveCallNotFound + } + return record, nil +} + +// ProxyLiveSideband 让认证后的客户端接管控制连接;媒体始终不经过这里。 +func (s *OpenAIGatewayService) ProxyLiveSideband( + ctx context.Context, + record *LiveCallRecord, + downstream *coderws.Conn, +) error { + if record == nil || downstream == nil { + return ErrLiveCallNotFound + } + store, err := s.liveStore() + if err != nil { + return err + } + owner := uuid.NewString() + claimed, err := store.ClaimLiveController(ctx, record.CallHash, LiveControllerProxy, owner) + if err != nil { + return err + } + if !claimed { + return ErrLiveControllerChanged + } + + // observer 轮询到接管状态后会关闭旧控制连接;同一个 call 可重新加入。 + time.Sleep(liveObserverPollInterval) + upstream, err := s.dialLiveSideband(ctx, record) + if err != nil { + _, _ = store.ReleaseLiveController(context.Background(), record.CallHash, owner) + go s.observeLiveCall(record.CallHash) + return err + } + defer func() { _ = upstream.Close() }() + downstream.SetReadLimit(openAIWSMessageReadLimitBytes) + + proxyCtx, cancel := context.WithCancel(ctx) + defer cancel() + errCh := make(chan error, 2) + go func() { + for { + messageType, payload, readErr := downstream.Read(proxyCtx) + if readErr != nil { + errCh <- readErr + return + } + if writeErr := upstream.WriteFrame(proxyCtx, messageType, payload); writeErr != nil { + errCh <- writeErr + return + } + } + }() + go func() { + for { + messageType, payload, readErr := upstream.ReadFrame(proxyCtx) + if readErr != nil { + errCh <- liveSidebandReadError(readErr) + return + } + if writeErr := downstream.Write(proxyCtx, messageType, payload); writeErr != nil { + errCh <- writeErr + return + } + if messageType == coderws.MessageText { + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if eventType == "session.closed" || eventType == "session.ended" { + errCh <- ErrLiveCallNotFound + return + } + } + } + }() + + runErr := s.runLiveController(proxyCtx, record, upstream, errCh) + cancel() + _, _ = store.ReleaseLiveController(context.Background(), record.CallHash, owner) + if liveSessionEnded(runErr) || !time.Now().Before(record.ExpiresAt) { + s.finalizeLiveCall(record) + return runErr + } + go s.observeLiveCall(record.CallHash) + return runErr +} + +// liveSessionEnded 判断控制连接的退出原因是否意味着会话已终结(应 finalize:写 +// usage log 并释放租约),而不是可以交给 observer 重连的临时错误。 +// +// ErrLiveUnavailable 在控制循环里只会来自租约续租失败。RefreshLiveLease 的 Lua 在 +// leaseID 被 GC 后不会重新写入,重连也拿不回并发槽 —— 若按临时错误重试,会话会以 +// 约 1 秒一轮的节奏空转到 ExpiresAt,期间持着上游连接却不计入任何并发限制。 +func liveSessionEnded(err error) bool { + return errors.Is(err, ErrLiveCallNotFound) || + errors.Is(err, ErrLiveUnavailable) || + errors.Is(err, context.DeadlineExceeded) +} + +func (s *OpenAIGatewayService) runLiveController( + ctx context.Context, + record *LiveCallRecord, + upstream liveFrameConn, + errCh <-chan error, +) error { + refreshTicker := time.NewTicker(liveLeaseRefreshInterval) + defer refreshTicker.Stop() + maxTimer := time.NewTimer(time.Until(record.ExpiresAt)) + defer maxTimer.Stop() + for { + select { + case <-ctx.Done(): + return context.Cause(ctx) + case err := <-errCh: + return err + case <-maxTimer.C: + closeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = upstream.WriteFrame(closeCtx, coderws.MessageText, []byte(`{"type":"session.close"}`)) + cancel() + return context.DeadlineExceeded + case <-refreshTicker.C: + if !s.refreshLiveLease(record) { + return ErrLiveUnavailable + } + } + } +} + +func (s *OpenAIGatewayService) observeLiveCall(callHash string) { + store, err := s.liveStore() + if err != nil { + return + } + owner := uuid.NewString() + claimed, err := store.ClaimLiveController(context.Background(), callHash, LiveControllerObserver, owner) + if err != nil || !claimed { + return + } + for { + record, getErr := store.GetLiveCall(context.Background(), callHash) + if getErr != nil || record.Controller != LiveControllerObserver { + return + } + if !time.Now().Before(record.ExpiresAt) { + s.finalizeLiveCall(record) + return + } + upstream, dialErr := s.dialLiveSideband(context.Background(), record) + if dialErr != nil { + if !s.waitForLiveObserverRetry(record) { + return + } + continue + } + runErr := s.runLiveObserverConnection(record, upstream) + _ = upstream.Close() + if errors.Is(runErr, ErrLiveControllerChanged) { + return + } + if liveSessionEnded(runErr) { + s.finalizeLiveCall(record) + return + } + if !s.waitForLiveObserverRetry(record) { + return + } + } +} + +func (s *OpenAIGatewayService) runLiveObserverConnection(record *LiveCallRecord, upstream liveFrameConn) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + frameCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + for { + messageType, payload, err := upstream.ReadFrame(ctx) + if err != nil { + select { + case errCh <- liveSidebandReadError(err): + case <-ctx.Done(): + } + return + } + if messageType == coderws.MessageText { + select { + case frameCh <- payload: + case <-ctx.Done(): + return + } + } + } + }() + refreshTicker := time.NewTicker(liveLeaseRefreshInterval) + defer refreshTicker.Stop() + controllerTicker := time.NewTicker(liveObserverPollInterval) + defer controllerTicker.Stop() + maxTimer := time.NewTimer(time.Until(record.ExpiresAt)) + defer maxTimer.Stop() + store, _ := s.liveStore() + for { + select { + case payload := <-frameCh: + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if eventType == "session.closed" || eventType == "session.ended" { + return ErrLiveCallNotFound + } + case err := <-errCh: + return err + case <-controllerTicker.C: + controller, err := store.GetLiveController(context.Background(), record.CallHash) + if err != nil { + return err + } + if controller != LiveControllerObserver { + return ErrLiveControllerChanged + } + case <-refreshTicker.C: + if !s.refreshLiveLease(record) { + return ErrLiveUnavailable + } + case <-maxTimer.C: + closeCtx, closeCancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = upstream.WriteFrame(closeCtx, coderws.MessageText, []byte(`{"type":"session.close"}`)) + closeCancel() + return context.DeadlineExceeded + } + } +} + +func (s *OpenAIGatewayService) waitForLiveObserverRetry(record *LiveCallRecord) bool { + timer := time.NewTimer(time.Second) + defer timer.Stop() + <-timer.C + store, err := s.liveStore() + if err != nil { + return false + } + controller, err := store.GetLiveController(context.Background(), record.CallHash) + // 过期不在此处判定:返回 true 让调用方回到循环顶部的过期分支,由它 finalize + // (写 usage log + 释放租约)。在这里直接返回 false 会让会话静默结束、不留记录。 + return err == nil && controller == LiveControllerObserver +} + +func (s *OpenAIGatewayService) refreshLiveLease(record *LiveCallRecord) bool { + cache, err := s.liveConcurrencyCache() + if err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), liveRedisOperationTimeout) + defer cancel() + refreshed, err := cache.RefreshLiveLease(ctx, record.AccountID, record.UserID, record.APIKeyID, record.LeaseID) + return err == nil && refreshed +} + +func (s *OpenAIGatewayService) releaseLiveLease(accountID, userID, apiKeyID int64, leaseID string) { + cache, err := s.liveConcurrencyCache() + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), liveRedisOperationTimeout) + defer cancel() + _ = cache.ReleaseLiveLease(ctx, accountID, userID, apiKeyID, leaseID) +} + +func (s *OpenAIGatewayService) finalizeLiveCall(record *LiveCallRecord) { + if record == nil { + return + } + store, err := s.liveStore() + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), liveRedisOperationTimeout) + first, err := store.MarkLiveCallClosed(ctx, record.CallHash, liveClosedRecordTTL) + cancel() + if err != nil || !first { + return + } + s.releaseLiveLease(record.AccountID, record.UserID, record.APIKeyID, record.LeaseID) + if s.usageLogRepo == nil { + return + } + duration := int(time.Since(record.CreatedAt).Milliseconds()) + if duration < 0 { + duration = 0 + } + inboundEndpoint := record.InboundEndpoint + upstreamEndpoint := "/backend-api/codex/realtime/calls" + userAgent := record.UserAgent + ipAddress := record.IPAddress + billingType := int8(BillingTypeBalance) + if record.SubscriptionID > 0 { + billingType = BillingTypeSubscription + } + _, _ = s.usageLogRepo.Create(context.Background(), &UsageLog{ + UserID: record.UserID, + APIKeyID: record.APIKeyID, + AccountID: record.AccountID, + RequestID: record.CallHash, + Model: record.Model, + RequestedModel: record.Model, + GroupID: liveOptionalID(record.GroupID), + SubscriptionID: liveOptionalID(record.SubscriptionID), + RateMultiplier: 1, + BillingType: billingType, + RequestType: RequestTypeLive, + DurationMs: &duration, + UserAgent: &userAgent, + IPAddress: &ipAddress, + InboundEndpoint: &inboundEndpoint, + UpstreamEndpoint: &upstreamEndpoint, + CreatedAt: record.CreatedAt, + }) +} diff --git a/backend/internal/service/openai_live_attestation.go b/backend/internal/service/openai_live_attestation.go new file mode 100644 index 0000000000..bc22eadec4 --- /dev/null +++ b/backend/internal/service/openai_live_attestation.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +const liveAttestationHeader = "x-oai-attestation" + +type liveAttestationAES struct { + key [32]byte +} + +func newLiveAttestationCipher(cfg *config.Config) SecretEncryptor { + if cfg == nil || strings.TrimSpace(cfg.JWT.Secret) == "" { + return nil + } + return &liveAttestationAES{ + key: sha256.Sum256([]byte("sub2api/live-attestation/v1\x00" + cfg.JWT.Secret)), + } +} + +func (c *liveAttestationAES) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate Live attestation nonce: %w", err) + } + encrypted := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawStdEncoding.EncodeToString(encrypted), nil +} + +func (c *liveAttestationAES) Decrypt(ciphertext string) (string, error) { + encrypted, err := base64.RawStdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("decode Live attestation: %w", err) + } + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(encrypted) < gcm.NonceSize() { + return "", errors.New("encrypted Live attestation is too short") + } + plaintext, err := gcm.Open(nil, encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():], nil) + if err != nil { + return "", fmt.Errorf("decrypt Live attestation: %w", err) + } + return string(plaintext), nil +} + +func (s *OpenAIGatewayService) prepareLiveAttestation(ctx context.Context) (string, string, error) { + if s == nil || s.liveAttestation == nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "Sub2API has no platform DeviceCheck provider", + } + } + if s.liveAttestationCipher == nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "JWT secret is required to protect the Sideband attestation", + } + } + header, err := s.liveAttestation.Generate(ctx) + if err != nil { + return "", "", &LiveAttestationUnavailableError{Reason: err.Error()} + } + ciphertext, err := s.liveAttestationCipher.Encrypt(header) + if err != nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "failed to protect the generated DeviceCheck attestation", + } + } + return header, ciphertext, nil +} + +func (s *OpenAIGatewayService) decryptLiveAttestation(record *LiveCallRecord) (string, error) { + if record == nil || strings.TrimSpace(record.AttestationCiphertext) == "" || s.liveAttestationCipher == nil { + return "", &LiveAttestationUnavailableError{ + Reason: "the Live call has no reusable DeviceCheck attestation", + } + } + header, err := s.liveAttestationCipher.Decrypt(record.AttestationCiphertext) + if err != nil { + return "", &LiveAttestationUnavailableError{ + Reason: "the Live call DeviceCheck attestation cannot be decrypted on this instance", + } + } + return header, nil +} diff --git a/backend/internal/service/openai_live_lifecycle_test.go b/backend/internal/service/openai_live_lifecycle_test.go new file mode 100644 index 0000000000..7ad2c1b0f2 --- /dev/null +++ b/backend/internal/service/openai_live_lifecycle_test.go @@ -0,0 +1,469 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +type liveTestFrame struct { + messageType coderws.MessageType + payload []byte + err error +} + +type liveTestFrameConn struct { + reads chan liveTestFrame + writes chan liveTestFrame + closed chan struct{} + closeOnce sync.Once +} + +func newLiveTestFrameConn() *liveTestFrameConn { + return &liveTestFrameConn{ + reads: make(chan liveTestFrame, 8), + writes: make(chan liveTestFrame, 8), + closed: make(chan struct{}), + } +} + +func (c *liveTestFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) { + select { + case frame := <-c.reads: + return frame.messageType, frame.payload, frame.err + case <-c.closed: + return coderws.MessageText, nil, coderws.CloseError{Code: coderws.StatusNormalClosure} + case <-ctx.Done(): + return coderws.MessageText, nil, context.Cause(ctx) + } +} + +func (c *liveTestFrameConn) WriteFrame(ctx context.Context, messageType coderws.MessageType, payload []byte) error { + frame := liveTestFrame{messageType: messageType, payload: append([]byte(nil), payload...)} + select { + case c.writes <- frame: + return nil + case <-c.closed: + return errors.New("connection closed") + case <-ctx.Done(): + return context.Cause(ctx) + } +} + +func (c *liveTestFrameConn) WriteJSON(ctx context.Context, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + return c.WriteFrame(ctx, coderws.MessageText, payload) +} + +func (c *liveTestFrameConn) ReadMessage(ctx context.Context) ([]byte, error) { + _, payload, err := c.ReadFrame(ctx) + return payload, err +} + +func (c *liveTestFrameConn) Ping(context.Context) error { return nil } + +func (c *liveTestFrameConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type liveTestDialer struct { + conn *liveTestFrameConn + url string + headers http.Header +} + +func (d *liveTestDialer) Dial( + _ context.Context, + wsURL string, + headers http.Header, + _ string, +) (openAIWSClientConn, int, http.Header, error) { + d.url = wsURL + d.headers = headers.Clone() + return d.conn, http.StatusSwitchingProtocols, nil, nil +} + +type liveTestAccountRepo struct { + AccountRepository + account *Account +} + +func (r *liveTestAccountRepo) GetByID(context.Context, int64) (*Account, error) { + return r.account, nil +} + +type liveTestStore struct { + GatewayCache + mu sync.Mutex + record *LiveCallRecord +} + +func (s *liveTestStore) SaveLiveCall(_ context.Context, record *LiveCallRecord, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + copy := *record + s.record = © + return nil +} + +func (s *liveTestStore) GetLiveCall(_ context.Context, callHash string) (*LiveCallRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.record == nil || s.record.CallHash != callHash { + return nil, ErrLiveCallNotFound + } + copy := *s.record + return ©, nil +} + +func (s *liveTestStore) ClaimLiveController(_ context.Context, callHash, controller, owner string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.record == nil || s.record.CallHash != callHash || s.record.Controller == LiveControllerClosed { + return false, nil + } + if controller == LiveControllerObserver && s.record.Controller != LiveControllerPending { + return false, nil + } + if controller == LiveControllerProxy && s.record.Controller != LiveControllerPending && s.record.Controller != LiveControllerObserver { + return false, nil + } + s.record.Controller = controller + s.record.ControllerOwner = owner + return true, nil +} + +func (s *liveTestStore) ReleaseLiveController(_ context.Context, callHash, owner string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.record == nil || s.record.CallHash != callHash || s.record.ControllerOwner != owner { + return false, nil + } + s.record.Controller = LiveControllerPending + s.record.ControllerOwner = "" + return true, nil +} + +func (s *liveTestStore) GetLiveController(_ context.Context, callHash string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.record == nil || s.record.CallHash != callHash { + return "", ErrLiveCallNotFound + } + return s.record.Controller, nil +} + +func (s *liveTestStore) MarkLiveCallClosed(_ context.Context, callHash string, _ time.Duration) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.record == nil || s.record.CallHash != callHash || s.record.Controller == LiveControllerClosed { + return false, nil + } + s.record.Controller = LiveControllerClosed + s.record.ControllerOwner = "" + return true, nil +} + +type liveTestConcurrencyCache struct { + ConcurrencyCache + mu sync.Mutex + releases int +} + +func (c *liveTestConcurrencyCache) AcquireLiveLease( + context.Context, + int64, + int, + int64, + int, + int64, + string, + bool, +) (bool, error) { + return true, nil +} + +func (c *liveTestConcurrencyCache) RefreshLiveLease( + context.Context, + int64, + int64, + int64, + string, +) (bool, error) { + return true, nil +} + +func (c *liveTestConcurrencyCache) ReleaseLiveLease( + context.Context, + int64, + int64, + int64, + string, +) error { + c.mu.Lock() + c.releases++ + c.mu.Unlock() + return nil +} + +type liveTestUsageRepo struct { + UsageLogRepository + mu sync.Mutex + logs []*UsageLog +} + +func (r *liveTestUsageRepo) Create(_ context.Context, log *UsageLog) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := *log + r.logs = append(r.logs, ©) + return true, nil +} + +func TestRunLiveControllerClosesExpiredSession(t *testing.T) { + upstream := newLiveTestFrameConn() + record := &LiveCallRecord{ExpiresAt: time.Now().Add(20 * time.Millisecond)} + service := &OpenAIGatewayService{} + + err := service.runLiveController(context.Background(), record, upstream, make(chan error)) + require.ErrorIs(t, err, context.DeadlineExceeded) + + select { + case frame := <-upstream.writes: + require.Equal(t, coderws.MessageText, frame.messageType) + require.JSONEq(t, `{"type":"session.close"}`, string(frame.payload)) + case <-time.After(time.Second): + t.Fatal("没有向上游发送 session.close") + } +} + +func TestFinalizeLiveCallIsIdempotentAndWritesZeroUsage(t *testing.T) { + record := &LiveCallRecord{ + CallID: "call_secret", + CallHash: hashLiveCallID("call_secret"), + AccountID: 11, + APIKeyID: 22, + UserID: 33, + GroupID: 44, + LeaseID: "lease-1", + Model: "gpt-live-test", + CreatedAt: time.Now().Add(-time.Second), + ExpiresAt: time.Now().Add(time.Hour), + Controller: LiveControllerPending, + InboundEndpoint: "/v1/live", + } + store := &liveTestStore{} + require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) + concurrencyCache := &liveTestConcurrencyCache{} + usageRepo := &liveTestUsageRepo{} + service := &OpenAIGatewayService{ + cache: store, + concurrencyService: NewConcurrencyService(concurrencyCache), + usageLogRepo: usageRepo, + } + + service.finalizeLiveCall(record) + service.finalizeLiveCall(record) + + concurrencyCache.mu.Lock() + require.Equal(t, 1, concurrencyCache.releases) + concurrencyCache.mu.Unlock() + usageRepo.mu.Lock() + require.Len(t, usageRepo.logs, 1) + log := usageRepo.logs[0] + usageRepo.mu.Unlock() + require.Equal(t, RequestTypeLive, log.RequestType) + require.Equal(t, record.CallHash, log.RequestID) + require.NotEqual(t, record.CallID, log.RequestID) + require.NotNil(t, log.DurationMs) + require.Zero(t, log.InputTokens) + require.Zero(t, log.OutputTokens) + require.Zero(t, log.TotalCost) + require.Zero(t, log.ActualCost) +} + +func TestGetLiveCallForIdentityRejectsMismatchedCaller(t *testing.T) { + groupID := int64(44) + record := &LiveCallRecord{ + CallID: "call_identity", + CallHash: hashLiveCallID("call_identity"), + APIKeyID: 22, + UserID: 33, + GroupID: groupID, + Controller: LiveControllerPending, + } + store := &liveTestStore{} + require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) + service := &OpenAIGatewayService{cache: store} + + _, err := service.GetLiveCallForIdentity(context.Background(), record.CallID, LiveCallIdentity{ + APIKeyID: 99, + UserID: record.UserID, + GroupID: &groupID, + }) + require.ErrorIs(t, err, ErrLiveIdentityMismatch) + + loaded, err := service.GetLiveCallForIdentity(context.Background(), record.CallID, LiveCallIdentity{ + APIKeyID: record.APIKeyID, + UserID: record.UserID, + GroupID: &groupID, + }) + require.NoError(t, err) + require.Equal(t, record.AccountID, loaded.AccountID) +} + +func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 2, + Credentials: map[string]any{ + "access_token": "test-access-token", + "chatgpt_account_id": "acct_test", + }, + } + record := &LiveCallRecord{ + CallID: "call_proxy", + CallHash: hashLiveCallID("call_proxy"), + AccountID: account.ID, + APIKeyID: 22, + UserID: 33, + LeaseID: "lease-1", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Minute), + Controller: LiveControllerPending, + } + attestationCipher := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "live-sideband-test-secret"}, + }) + var err error + record.AttestationCiphertext, err = attestationCipher.Encrypt(`{"v":1,"s":0,"t":"v1.sideband"}`) + require.NoError(t, err) + store := &liveTestStore{} + require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) + upstream := newLiveTestFrameConn() + dialer := &liveTestDialer{conn: upstream} + service := &OpenAIGatewayService{ + accountRepo: &liveTestAccountRepo{account: account}, + cache: store, + openaiWSPassthroughDialer: dialer, + liveAttestationCipher: attestationCipher, + } + proxyResult := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + downstream, err := coderws.Accept(writer, request, nil) + if err != nil { + proxyResult <- err + return + } + defer func() { _ = downstream.CloseNow() }() + proxyResult <- service.ProxyLiveSideband(request.Context(), record, downstream) + })) + defer server.Close() + + client, _, err := coderws.Dial( + context.Background(), + "ws"+strings.TrimPrefix(server.URL, "http"), + nil, + ) + require.NoError(t, err) + defer func() { _ = client.CloseNow() }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + require.NoError(t, client.Write(ctx, coderws.MessageText, []byte(`{"type":"client.text"}`))) + clientText := <-upstream.writes + require.Equal(t, coderws.MessageText, clientText.messageType) + require.JSONEq(t, `{"type":"client.text"}`, string(clientText.payload)) + + require.NoError(t, client.Write(ctx, coderws.MessageBinary, []byte{1, 2, 3})) + clientBinary := <-upstream.writes + require.Equal(t, coderws.MessageBinary, clientBinary.messageType) + require.Equal(t, []byte{1, 2, 3}, clientBinary.payload) + + upstream.reads <- liveTestFrame{messageType: coderws.MessageText, payload: []byte(`{"type":"server.text"}`)} + messageType, payload, err := client.Read(ctx) + require.NoError(t, err) + require.Equal(t, coderws.MessageText, messageType) + require.JSONEq(t, `{"type":"server.text"}`, string(payload)) + + upstream.reads <- liveTestFrame{messageType: coderws.MessageBinary, payload: []byte{4, 5, 6}} + messageType, payload, err = client.Read(ctx) + require.NoError(t, err) + require.Equal(t, coderws.MessageBinary, messageType) + require.Equal(t, []byte{4, 5, 6}, payload) + + require.Equal(t, "wss://chatgpt.com/backend-api/codex/call_proxy", dialer.url) + require.Equal(t, "Bearer test-access-token", dialer.headers.Get("Authorization")) + require.Equal(t, "acct_test", dialer.headers.Get("Chatgpt-Account-Id")) + require.Equal(t, `{"v":1,"s":0,"t":"v1.sideband"}`, dialer.headers.Get(liveAttestationHeader)) + upstream.reads <- liveTestFrame{err: coderws.CloseError{Code: coderws.StatusNormalClosure}} + require.ErrorIs(t, <-proxyResult, ErrLiveCallNotFound) +} + +// TestLiveSessionEndedTreatsLeaseLossAsTerminal 锁定:租约续租失败(ErrLiveUnavailable) +// 必须判为会话终结。RefreshLiveLease 的 Lua 在 leaseID 被 GC 后不会重新写入,若把它 +// 当临时错误交给 observer 重连,会话会空转到 ExpiresAt 且不计入任何并发限制。 +func TestLiveSessionEndedTreatsLeaseLossAsTerminal(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"租约丢失", ErrLiveUnavailable, true}, + {"租约丢失(被包装)", fmt.Errorf("refresh live lease: %w", ErrLiveUnavailable), true}, + {"上游报告会话已关闭", ErrLiveCallNotFound, true}, + {"到达会话时长上限", context.DeadlineExceeded, true}, + {"控制权被他人接管", ErrLiveControllerChanged, false}, + {"临时读错误", errors.New("unexpected EOF"), false}, + {"无错误", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, liveSessionEnded(tc.err)) + }) + } +} + +// TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize 锁定:已过期但控制权仍在 +// observer 手上时返回 true,让调用方回到 observeLiveCall 循环顶部的过期分支去 +// finalize(写 usage log + 释放租约)。在此处直接返回 false 会让会话静默结束、不留记录。 +func TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize(t *testing.T) { + record := &LiveCallRecord{ + CallID: "call_expired", + CallHash: hashLiveCallID("call_expired"), + Controller: LiveControllerObserver, + ExpiresAt: time.Now().Add(-time.Minute), + } + store := &liveTestStore{} + require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) + svc := &OpenAIGatewayService{cache: store} + + require.True(t, svc.waitForLiveObserverRetry(record), + "过期判定必须留给循环顶部,否则不会写 usage log") + + // 控制权已被他人接管时仍必须停止重试,避免与新控制者抢同一个 call。 + require.NoError(t, store.SaveLiveCall(context.Background(), &LiveCallRecord{ + CallID: record.CallID, + CallHash: record.CallHash, + Controller: LiveControllerProxy, + ExpiresAt: time.Now().Add(time.Hour), + }, time.Hour)) + require.False(t, svc.waitForLiveObserverRetry(record)) +} diff --git a/backend/internal/service/openai_live_test.go b/backend/internal/service/openai_live_test.go new file mode 100644 index 0000000000..81cff13b8c --- /dev/null +++ b/backend/internal/service/openai_live_test.go @@ -0,0 +1,242 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +type liveHTTPUpstreamStub struct { + request *http.Request + body []byte +} + +type liveAttestationStub struct { + header string + err error +} + +func (s liveAttestationStub) Check(context.Context) error { + return s.err +} + +func (s liveAttestationStub) Generate(context.Context) (string, error) { + return s.header, s.err +} + +func (s *liveHTTPUpstreamStub) Do( + request *http.Request, + _ string, + _ int64, + _ int, +) (*http.Response, error) { + s.request = request + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + s.body = body + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Location": {"/backend-api/codex/call_test"}, + }, + Body: io.NopCloser(strings.NewReader("v=0\r\n")), + }, nil +} + +func (s *liveHTTPUpstreamStub) DoWithTLS( + request *http.Request, + proxyURL string, + accountID int64, + accountConcurrency int, + _ *tlsfingerprint.Profile, +) (*http.Response, error) { + return s.Do(request, proxyURL, accountID, accountConcurrency) +} + +func TestLiveCapabilityOnlyAllowsOpenAIOAuth(t *testing.T) { + require.True(t, (&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive)) + require.False(t, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive)) + require.False(t, (&Account{Platform: PlatformGrok, Type: AccountTypeOAuth}).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive)) + require.False(t, (&Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + openAIAuthModeCredentialKey: OpenAIAuthModePersonalAccessToken, + }, + }).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive)) + require.False(t, (&Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + openAIAuthModeCredentialKey: OpenAIAuthModeAgentIdentity, + }, + }).SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive)) +} + +func TestValidateLiveCallRequestDoesNotRequireDelegation(t *testing.T) { + request := &LiveCallRequest{ + SDP: "v=0\r\n", + Session: json.RawMessage(`{"model":"gpt-live-test","instructions":"hello"}`), + } + require.NoError(t, ValidateLiveCallRequest(request)) + require.NotContains(t, string(request.Session), "delegation") +} + +func TestCreateUpstreamLiveCallPreservesSession(t *testing.T) { + upstream := &liveHTTPUpstreamStub{} + service := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 2, + Credentials: map[string]any{ + "access_token": "test-access-token", + "chatgpt_account_id": "acct_test", + }, + } + session := json.RawMessage(`{ + "model":"gpt-live-test", + "delegation":{"type":"client"}, + "custom":{"keep":true} + }`) + + created, err := service.createUpstreamLiveCall(context.Background(), account, &LiveCallRequest{ + SDP: "v=offer\r\n", + Session: session, + }, `{"v":1,"s":0,"t":"v1.test"}`) + require.NoError(t, err) + require.Equal(t, "call_test", created.CallID) + require.Equal(t, []byte("v=0\r\n"), created.SDP) + + var forwarded struct { + SDP string `json:"sdp"` + Session json.RawMessage `json:"session"` + } + require.NoError(t, json.Unmarshal(upstream.body, &forwarded)) + require.Equal(t, "v=offer\r\n", forwarded.SDP) + require.JSONEq(t, string(session), string(forwarded.Session)) + require.Equal(t, "Bearer test-access-token", upstream.request.Header.Get("Authorization")) + require.Equal(t, "acct_test", upstream.request.Header.Get("Chatgpt-Account-Id")) + require.Equal(t, "quicksilver=v2", upstream.request.Header.Get("OpenAI-Alpha")) + require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, upstream.request.Header.Get(liveAttestationHeader)) + require.NotEmpty(t, upstream.request.Header.Get("Session-Id")) + require.NotEmpty(t, upstream.request.Header.Get("Thread-Id")) + require.Empty(t, upstream.request.Header.Get("OpenAI-Beta")) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.request.Context())) + require.True(t, HTTPUpstreamRedirectsDisabled(upstream.request.Context())) +} + +func TestLiveAttestationCipherRoundTripAndRejectsOtherInstanceKey(t *testing.T) { + first := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "first-live-secret"}, + }) + second := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "second-live-secret"}, + }) + require.NotNil(t, first) + require.NotNil(t, second) + + ciphertext, err := first.Encrypt(`{"v":1,"s":0,"t":"v1.opaque"}`) + require.NoError(t, err) + require.NotContains(t, ciphertext, "opaque") + + plaintext, err := first.Decrypt(ciphertext) + require.NoError(t, err) + require.Equal(t, `{"v":1,"s":0,"t":"v1.opaque"}`, plaintext) + + _, err = second.Decrypt(ciphertext) + require.Error(t, err) +} + +func TestPrepareLiveAttestationEncryptsHeaderAndReturnsExplicitProviderError(t *testing.T) { + cipher := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "live-attestation-test-secret"}, + }) + service := &OpenAIGatewayService{ + liveAttestation: liveAttestationStub{header: `{"v":1,"s":0,"t":"v1.test"}`}, + liveAttestationCipher: cipher, + } + header, ciphertext, err := service.prepareLiveAttestation(context.Background()) + require.NoError(t, err) + require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, header) + require.NotContains(t, ciphertext, "v1.test") + decrypted, err := cipher.Decrypt(ciphertext) + require.NoError(t, err) + require.Equal(t, header, decrypted) + + service.liveAttestation = liveAttestationStub{err: errors.New("macOS app missing")} + _, _, err = service.prepareLiveAttestation(context.Background()) + var unavailable *LiveAttestationUnavailableError + require.ErrorAs(t, err, &unavailable) + require.Contains(t, unavailable.Error(), "macOS app missing") +} + +func TestLiveMaxSessionDurationDefaultsAndOverrides(t *testing.T) { + require.Equal(t, defaultLiveMaxSessionDuration, (&OpenAIGatewayService{}).liveMaxSessionDuration()) + require.Equal( + t, + 90*time.Second, + (&OpenAIGatewayService{cfg: &config.Config{ + Gateway: config.GatewayConfig{ + Live: config.GatewayLiveConfig{MaxSessionDurationSeconds: 90}, + }, + }}).liveMaxSessionDuration(), + ) +} + +func TestLiveSidebandNormalCloseEndsCall(t *testing.T) { + normalClose := coderws.CloseError{Code: coderws.StatusNormalClosure} + require.ErrorIs(t, liveSidebandReadError(normalClose), ErrLiveCallNotFound) + + abnormalClose := coderws.CloseError{Code: coderws.StatusInternalError} + require.Equal(t, abnormalClose, liveSidebandReadError(abnormalClose)) +} + +func TestLiveCreateFailoverUsesExistingOpenAIPolicy(t *testing.T) { + service := &OpenAIGatewayService{} + require.False(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + StatusCode: http.StatusBadRequest, + ResponseBody: []byte(`{"error":{"message":"invalid session"}}`), + })) + require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + StatusCode: http.StatusForbidden, + })) + require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + })) + require.True(t, service.shouldFailoverLiveCreateError(errors.New("transport failed"))) +} + +func TestLiveCallIDFromLocation(t *testing.T) { + callID, err := liveCallIDFromLocation("https://chatgpt.com/backend-api/codex/call_123?intent=quicksilver") + require.NoError(t, err) + require.Equal(t, "call_123", callID) + + callID, err = liveCallIDFromLocation("/backend-api/codex/call_456") + require.NoError(t, err) + require.Equal(t, "call_456", callID) +} + +func TestRequestTypeLive(t *testing.T) { + require.True(t, RequestTypeLive.IsValid()) + require.Equal(t, "live", RequestTypeLive.String()) + parsed, err := ParseUsageRequestType("live") + require.NoError(t, err) + require.Equal(t, RequestTypeLive, parsed) +} diff --git a/backend/internal/service/openai_live_types.go b/backend/internal/service/openai_live_types.go new file mode 100644 index 0000000000..8ef586f217 --- /dev/null +++ b/backend/internal/service/openai_live_types.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +const ( + LiveControllerPending = "pending" + LiveControllerObserver = "observer" + LiveControllerProxy = "proxy" + LiveControllerClosed = "closed" +) + +var ( + ErrLiveUnavailable = errors.New("live is unavailable") + ErrLiveConcurrencyFull = errors.New("live concurrency is full") + ErrLiveCallNotFound = errors.New("live call not found") + ErrLiveIdentityMismatch = errors.New("live call identity mismatch") + ErrLiveControllerChanged = errors.New("live controller changed") +) + +type LiveAttestationUnavailableError struct { + Reason string +} + +func (e *LiveAttestationUnavailableError) Error() string { + if e == nil || e.Reason == "" { + return "Live attestation is unavailable" + } + return "Live attestation is unavailable: " + e.Reason +} + +// LiveCallRequest 是两个下游创建协议归一后的请求。Session 不做结构改写。 +type LiveCallRequest struct { + SDP string `json:"sdp"` + Session json.RawMessage `json:"session"` +} + +type LiveCallIdentity struct { + APIKeyID int64 + UserID int64 + GroupID *int64 + SubscriptionID *int64 + UserAgent string + IPAddress string + InboundEndpoint string +} + +type LiveCallRecord struct { + CallID string + CallHash string + AccountID int64 + APIKeyID int64 + UserID int64 + GroupID int64 + SubscriptionID int64 + LeaseID string + Model string + CreatedAt time.Time + ExpiresAt time.Time + Controller string + ControllerOwner string + UserAgent string + IPAddress string + InboundEndpoint string + // AttestationCiphertext 仅用于让同一会话的 Sideband 复用创建时的证明。 + AttestationCiphertext string +} + +type LiveCallCreated struct { + SDP []byte + CallID string + Location string + Account *Account +} + +// LiveCallStore 由 GatewayCache 的 Redis 实现可选提供,避免扩大旧缓存接口。 +type LiveCallStore interface { + SaveLiveCall(ctx context.Context, record *LiveCallRecord, ttl time.Duration) error + GetLiveCall(ctx context.Context, callHash string) (*LiveCallRecord, error) + ClaimLiveController(ctx context.Context, callHash, controller, owner string) (bool, error) + ReleaseLiveController(ctx context.Context, callHash, owner string) (bool, error) + GetLiveController(ctx context.Context, callHash string) (string, error) + MarkLiveCallClosed(ctx context.Context, callHash string, ttl time.Duration) (bool, error) +} + +type LiveConcurrencyCache interface { + AcquireLiveLease( + ctx context.Context, + accountID int64, + accountMax int, + userID int64, + userMax int, + apiKeyID int64, + leaseID string, + replacingRegularSlots bool, + ) (bool, error) + RefreshLiveLease(ctx context.Context, accountID, userID, apiKeyID int64, leaseID string) (bool, error) + ReleaseLiveLease(ctx context.Context, accountID, userID, apiKeyID int64, leaseID string) error +} diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index d4f6e22203..43f8109f88 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -450,7 +450,10 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]} ], "tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"}, - "input":[{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}] + "input":[ + {"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}, + {"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]} + ] }`) upstreamSSE := strings.Join([]string{ @@ -488,6 +491,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").Exists()) require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists()) + require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String()) + require.Len(t, upstream.bodies, 1) downstream := rec.Body.String() require.NotContains(t, downstream, "collaboration__spawn_agent") diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go new file mode 100644 index 0000000000..27b7dea0f5 --- /dev/null +++ b/backend/internal/service/openai_responses_item_id.go @@ -0,0 +1,78 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Invalid replayed IDs are removed rather than rewritten because a fabricated +// msg/fc ID may point at a different upstream object. +func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool { + if id == "" { + return false + } + if itemType == "message" { + return !strings.HasPrefix(id, "msg") + } + if isCodexToolCallInputType(itemType) { + return !strings.HasPrefix(id, "fc") + } + return false +} + +func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, false, nil + } + + items := make([][]byte, 0) + changed := false + var sanitizeErr error + index := 0 + input.ForEach(func(_, item gjson.Result) bool { + currentIndex := index + index++ + itemBody := []byte(item.Raw) + if item.IsObject() { + itemType := item.Get("type") + id := item.Get("id") + if itemType.Type == gjson.String && id.Type == gjson.String && + shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) { + itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id") + if sanitizeErr != nil { + sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr) + return false + } + changed = true + } + } + items = append(items, itemBody) + return true + }) + if sanitizeErr != nil { + return nil, false, sanitizeErr + } + if !changed { + return body, false, nil + } + + rebuiltInput := make([]byte, 0, len(input.Raw)) + rebuiltInput = append(rebuiltInput, '[') + for i, item := range items { + if i > 0 { + rebuiltInput = append(rebuiltInput, ',') + } + rebuiltInput = append(rebuiltInput, item...) + } + rebuiltInput = append(rebuiltInput, ']') + + sanitized, err := sjson.SetRawBytes(body, "input", rebuiltInput) + if err != nil { + return nil, false, fmt.Errorf("replace sanitized input: %w", err) + } + return sanitized, true, nil +} diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index 4c3f433bc4..bd548b69ec 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -7,6 +7,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names" @@ -26,6 +28,19 @@ func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUp return true } +// shouldStripOpenAIResponsesInputNamespaces removes residual input item +// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps +// namespaces because that protocol supports them and does not restore payloads. +func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool { + if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) { + return false + } + if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { + return false + } + return true +} + func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil @@ -49,6 +64,55 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro return rebuilt, nil } +// stripOpenAIResponsesInputNamespaces removes namespace only from direct input +// array items. Namespace declarations and nested namespace fields are left +// untouched. Rebuilding the input array once keeps this linear for long +// histories and avoids decoding JSON numbers through float64. +func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { + if !bytes.Contains(body, []byte(`"namespace"`)) { + return body, nil + } + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, nil + } + + var rebuilt bytes.Buffer + rebuilt.Grow(len(input.Raw)) + _ = rebuilt.WriteByte('[') + changed := false + first := true + var stripErr error + input.ForEach(func(_, item gjson.Result) bool { + if !first { + _ = rebuilt.WriteByte(',') + } + first = false + itemBody := []byte(item.Raw) + if item.IsObject() && item.Get("namespace").Exists() { + itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace") + if stripErr != nil { + return false + } + changed = true + } + _, _ = rebuilt.Write(itemBody) + return true + }) + if stripErr != nil { + return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr) + } + if !changed { + return body, nil + } + _ = rebuilt.WriteByte(']') + stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes()) + if err != nil { + return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err) + } + return stripped, nil +} + func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) { if c != nil && len(names) > 0 { c.Set(openAIResponsesNamespaceNamesContextKey, names) diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 00b8772d13..0b12355a3d 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -1,9 +1,11 @@ package service import ( + "strconv" "testing" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { @@ -34,3 +36,78 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { }) } } + +func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken} + grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} + + tests := []struct { + name string + account *Account + transport OpenAIUpstreamTransport + passthroughEnabled bool + want bool + }{ + {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + {name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + {name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + {name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, shouldStripOpenAIResponsesInputNamespaces(tt.account, tt.transport, tt.passthroughEnabled)) + }) + } +} + +func TestStripOpenAIResponsesInputNamespaces(t *testing.T) { + body := []byte(`{ + "meta":9007199254740993, + "scientific":1.25e+42, + "escaped":"line\\n\\u003ctag\\u003e", + "tools":[{"type":"function","name":"keep","namespace":"tool-namespace"}], + "input":[ + {"type":"function_call","namespace":"n0","name":"one","content":{"namespace":"nested"},"large":9007199254740993}, + {"type":"message","namespace":"n1","content":[{"type":"input_text","text":"hello","namespace":"nested-content"}]}, + {"type":"custom_tool_call","namespace":"n2","input":"{}"}, + {"type":"function_call_output","namespace":"n3","output":"ok"}, + {"type":"item","namespace":"n4"}, + {"type":"item","namespace":"n5"}, + {"type":"item","namespace":"n6"}, + {"type":"item","namespace":"n7"} + ] + }`) + + stripped, err := stripOpenAIResponsesInputNamespaces(body) + require.NoError(t, err) + for index := 0; index < 8; index++ { + require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists()) + } + require.Equal(t, "nested", gjson.GetBytes(stripped, "input.0.content.namespace").String()) + require.Equal(t, "nested-content", gjson.GetBytes(stripped, "input.1.content.0.namespace").String()) + require.Equal(t, "tool-namespace", gjson.GetBytes(stripped, "tools.0.namespace").String()) + require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw) + require.Equal(t, gjson.GetBytes(body, "scientific").Raw, gjson.GetBytes(stripped, "scientific").Raw) + require.Equal(t, gjson.GetBytes(body, "escaped").Raw, gjson.GetBytes(stripped, "escaped").Raw) + require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw) +} + +func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testing.T) { + tests := [][]byte{ + []byte(`{"input":"text","namespace":"top-level"}`), + []byte(`{"input":{"namespace":"single-object"}}`), + []byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`), + } + for _, body := range tests { + stripped, err := stripOpenAIResponsesInputNamespaces(body) + require.NoError(t, err) + require.Equal(t, body, stripped) + } +} diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 96c20de508..724fd90c37 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -114,10 +114,9 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRej require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists()) } -func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) { - body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`) +func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`) upstream := &httpUpstreamRecorder{responses: []*http.Response{ - newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace","type":"invalid_request_error"}}`), newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), }} @@ -130,9 +129,44 @@ func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) require.NoError(t, err) require.NotNil(t, result) - require.Len(t, upstream.bodies, 2) - require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.namespace").String()) - require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists()) + require.Len(t, upstream.bodies, 1) + require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists()) +} + +func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) { + accounts := []struct { + name string + account *Account + }{ + {name: "oauth", account: newOpenAIOAuthNamespaceTestAccount()}, + {name: "apikey", account: newOpenAIRejectedFieldTestAccount()}, + } + for _, tt := range accounts { + for _, path := range []string{"/v1/responses", "/v1/responses/compact"} { + t.Run(tt.name+path, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"test","input":[{"type":"message","role":"user","namespace":"remove","content":[{"type":"input_text","text":"hello","namespace":"nested-keep"}]}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_namespace_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + c := newOpenAIRejectedFieldTestContext(body) + c.Request.URL.Path = path + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + c, + tt.account, + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1, "namespace must be removed before the first upstream request") + require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists()) + require.Equal(t, "nested-keep", gjson.GetBytes(upstream.bodies[0], "input.0.content.0.namespace").String()) + }) + } + } } func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) { @@ -157,10 +191,9 @@ func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String()) } -func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) { - body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`) +func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`) upstream := &httpUpstreamRecorder{responses: []*http.Response{ - newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace"}}`), newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`), newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), }} @@ -174,12 +207,13 @@ func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) require.NoError(t, err) require.NotNil(t, result) - require.Len(t, upstream.bodies, 3) - require.True(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists()) - require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists()) - require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Int()) - require.False(t, gjson.GetBytes(upstream.bodies[2], "input.1.namespace").Exists()) - require.False(t, gjson.GetBytes(upstream.bodies[2], "max_output_tokens").Exists()) + require.Len(t, upstream.bodies, 2) + for _, forwardedBody := range upstream.bodies { + require.False(t, gjson.GetBytes(forwardedBody, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(forwardedBody, "input.1.namespace").Exists()) + } + require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists()) } func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService { @@ -221,6 +255,22 @@ func newOpenAIRejectedFieldTestAccount() *Account { } } +func newOpenAIOAuthNamespaceTestAccount() *Account { + return &Account{ + ID: 5108, + Name: "openai-oauth-namespace", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + Status: StatusActive, + Schedulable: true, + } +} + func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response { return &http.Response{ StatusCode: status, diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 2e2fa90f57..4093ca0b64 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -124,6 +124,11 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co return err } + // Transport attempt reached the network path; count as Ollama Cloud activity. + if s != nil { + scheduleOllamaCloudUsageActivity(s.deferredService, account) + } + if classifyOpenAITransportError(err).Persistent { s.tempUnscheduleOpenAITransportError(ctx, account, safeErr) } diff --git a/backend/internal/service/openai_upstream_transport_error_handle_test.go b/backend/internal/service/openai_upstream_transport_error_handle_test.go index 3ea8b43181..38e392893e 100644 --- a/backend/internal/service/openai_upstream_transport_error_handle_test.go +++ b/backend/internal/service/openai_upstream_transport_error_handle_test.go @@ -210,3 +210,68 @@ func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) { require.Empty(t, repo.tempUnschedCalls, "plain EOF is transient: fail over but do not evict") require.Equal(t, 0, rec.Body.Len(), "service must not write a hard 502 before handler can fail over") } + +func TestHandleOpenAIUpstreamTransportError_RecordsOllamaActivityOnly(t *testing.T) { + deferred := NewDeferredService(nil, nil, time.Second) + svc := &OpenAIGatewayService{ + accountRepo: &openaiTransportAccountRepoStub{}, + deferredService: deferred, + } + ollama := &Account{ + ID: 501, Name: "ollama-cloud", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + } + other := &Account{ + ID: 502, Name: "openai-official", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k-openai", "base_url": "https://api.openai.com"}, + } + c, _ := newOpenAITransportErrTestContext() + + _ = svc.handleOpenAIUpstreamTransportError(context.Background(), c, ollama, errors.New("connection reset"), false) + _ = svc.handleOpenAIUpstreamTransportError(context.Background(), c, other, errors.New("connection reset"), false) + + _, ok := deferred.lastUsedUpdates.Load(int64(501)) + require.True(t, ok, "Ollama Cloud transport error must schedule last_used activity") + _, ok = deferred.lastUsedUpdates.Load(int64(502)) + require.False(t, ok, "non-Ollama transport error must not schedule Ollama activity") +} + +func TestHandleOpenAIUpstreamTransportError_ContextCanceledSkipsOllamaActivity(t *testing.T) { + deferred := NewDeferredService(nil, nil, time.Second) + svc := &OpenAIGatewayService{ + accountRepo: &openaiTransportAccountRepoStub{}, + deferredService: deferred, + } + ollama := &Account{ + ID: 503, Name: "ollama-canceled", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + } + c, _ := newOpenAITransportErrTestContext() + + err := svc.handleOpenAIUpstreamTransportError(context.Background(), c, ollama, context.Canceled, false) + + require.ErrorIs(t, err, context.Canceled) + _, ok := deferred.lastUsedUpdates.Load(int64(503)) + require.False(t, ok, "context.Canceled is client disconnect before a fault; do not count as Ollama activity") +} + +func TestHandleOpenAIAccountUpstreamError_RecordsOllamaActivityOnly(t *testing.T) { + deferred := NewDeferredService(nil, nil, time.Second) + svc := &OpenAIGatewayService{deferredService: deferred} + ollama := &Account{ + ID: 504, Name: "ollama-429", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"}, + } + other := &Account{ + ID: 505, Name: "openai-429", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k-openai", "base_url": "https://api.openai.com"}, + } + + _ = svc.handleOpenAIAccountUpstreamError(context.Background(), ollama, http.StatusTooManyRequests, http.Header{}, []byte(`{"error":{"message":"rate"}}`), "gpt-test") + _ = svc.handleOpenAIAccountUpstreamError(context.Background(), other, http.StatusTooManyRequests, http.Header{}, []byte(`{"error":{"message":"rate"}}`), "gpt-test") + + _, ok := deferred.lastUsedUpdates.Load(int64(504)) + require.True(t, ok, "Ollama Cloud non-2xx must schedule last_used activity") + _, ok = deferred.lastUsedUpdates.Load(int64(505)) + require.False(t, ok, "non-Ollama non-2xx must not schedule Ollama activity") +} diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 5872f125bf..c7cbbc0b2f 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -733,7 +733,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T }, } - body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello"}]}`) + body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello","namespace":"native-wsv2"}]}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) @@ -745,6 +745,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.False(t, gjson.Get(requestJSON, "store").Bool(), "默认策略应将 OAuth store 置为 false") require.True(t, gjson.Get(requestJSON, "stream").Exists(), "WSv2 payload 应保留 stream 字段") require.True(t, gjson.Get(requestJSON, "stream").Bool(), "OAuth Codex 规范化后应强制 stream=true") + require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) // OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离, diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 814d6ddaa1..636bd690cb 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -218,6 +218,11 @@ func (s *PricingService) Stop() { // startUpdateScheduler 启动定时更新调度器 func (s *PricingService) startUpdateScheduler() { + if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" { + logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty") + return + } + // 定期检查哈希更新 hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute if hashInterval < time.Minute { @@ -789,6 +794,10 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { // 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。 // 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。 families := []modelFamily{ + // Opus 5 与 Opus 4.8 同价($5/$25 per MTok)。定价数据缺失 claude-opus-5 时 + // 必须回退到 4.8,否则会掉进 "opus-4" 系列按 $15/$75 计费(3 倍超收)。 + {name: "opus-5", match: []string{"claude-opus-5"}, pricing: []string{"claude-opus-5", "claude-opus-4-8"}}, + {name: "opus-4.8", match: []string{"claude-opus-4-8", "claude-opus-4.8"}, pricing: []string{"claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7"}}, {name: "opus-4.7", match: []string{"claude-opus-4-7", "claude-opus-4.7"}, pricing: []string{"claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6"}}, {name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}}, {name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}}, @@ -821,6 +830,11 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { switch { case strings.Contains(model, "opus"): switch { + // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 + case strings.Contains(model, "opus-5") || strings.Contains(model, "opus5"): + fallbackName = "opus-5" + case strings.Contains(model, "4.8") || strings.Contains(model, "4-8"): + fallbackName = "opus-4.8" case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"): fallbackName = "opus-4.7" case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"): diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index b68d034638..88553f0fbf 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -5,11 +5,42 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) +func TestPricingSchedulerBlankRemoteURLDoesNotStart(t *testing.T) { + svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{RemoteURL: " \t "}}, nil) + defer svc.Stop() + + svc.startUpdateScheduler() + done := make(chan struct{}) + go func() { + svc.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("blank remote URL must not start scheduler") + } +} + +func TestPricingNonEmptyInvalidRemoteURLStillReturnsValidationError(t *testing.T) { + svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{ + RemoteURL: "://invalid", + DataDir: t.TempDir(), + }}, nil) + + err := svc.ForceUpdate() + + require.Error(t, err) + require.Contains(t, err.Error(), "invalid pricing url") +} + func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) { svc := &PricingService{} body := []byte(`{ diff --git a/backend/internal/service/registration_email_alias.go b/backend/internal/service/registration_email_alias.go new file mode 100644 index 0000000000..297a50dcd5 --- /dev/null +++ b/backend/internal/service/registration_email_alias.go @@ -0,0 +1,142 @@ +package service + +import ( + "context" + "strings" +) + +// Email alias normalization for registration dedup. +// +// Problem: registration duplicate checks compare the email verbatim (lowercase +// + trim only), so a single real inbox can spawn unlimited accounts using +// provider alias features: +// - Plus addressing: user+tag@gmail.com is delivered to user@gmail.com +// (supported by Gmail, Outlook/Hotmail, Yahoo, iCloud, Fastmail, and more). +// - Gmail dot trick: u.s.e.r@gmail.com is delivered to user@gmail.com. +// - FQDN root dot: user@gmail.com. is the absolute form of user@gmail.com, +// passes the registration validator, and reaches the same mailbox. +// +// This lets abusers bulk-register accounts (e.g. to farm signup grants) while +// the domain whitelist and email verification see each variant as a distinct, +// deliverable address. NormalizeEmailForAliasDedup collapses these variants to +// a single "inbox identity" so the registration path can reject duplicates. +// +// Normalization rules: +// - All domains: lowercase, trim, drop the FQDN root dot, and strip the +// local-part "+suffix". Stripping the plus suffix on domains that do not +// support plus addressing is harmless — those exact addresses are virtually +// never registered. +// - Gmail family (gmail.com / googlemail.com): additionally remove dots from +// the local part and fold the domain to gmail.com. +// +// This only affects registration duplicate detection. It intentionally does not +// change how emails are stored, displayed, or used for login/delivery. + +var gmailFamilyDomains = map[string]struct{}{ + "gmail.com": {}, + "googlemail.com": {}, +} + +// NormalizeEmailForAliasDedup returns the canonical "inbox identity" of an +// email. Malformed input is returned lowercased/trimmed unchanged; format +// validation is the caller's responsibility. +func NormalizeEmailForAliasDedup(email string) string { + local, domain, ok := splitEmailForAliasDedup(email) + if !ok { + return strings.ToLower(strings.TrimSpace(email)) + } + local = stripEmailPlusSuffix(local) + if isGmailFamilyDomain(domain) { + local = stripEmailLocalDots(local) + domain = "gmail.com" + } + return local + "@" + domain +} + +// EmailAliasProbe describes a shape a stored duplicate can have, expressed on the +// dot-stripped email form used by UserRepository.ExistsByEmailAlias: Local is the +// plus-stripped and dot-stripped local part, Domain the dot-stripped candidate +// domain. Dots are removed on both sides of that comparison so one probe per +// domain also covers the Gmail dot trick and the FQDN root dot. The over-matching +// this introduces (dots stay significant outside the Gmail family) is filtered by +// re-checking every candidate with NormalizeEmailForAliasDedup. +type EmailAliasProbe struct { + Local string + Domain string +} + +// EmailAliasDedupProbes returns the probes covering every stored address that +// could resolve to the same inbox as email: gmail-family domains are mutual +// aliases, every other domain only collides with itself. It returns nil when +// there is nothing to probe (malformed address, or a local part made of dots +// only, which no provider delivers). +func EmailAliasDedupProbes(email string) []EmailAliasProbe { + local, domain, ok := splitEmailForAliasDedup(email) + if !ok { + return nil + } + probeLocal := strings.ReplaceAll(stripEmailPlusSuffix(local), ".", "") + if probeLocal == "" { + return nil + } + domains := []string{domain} + if isGmailFamilyDomain(domain) { + domains = []string{"gmail.com", "googlemail.com"} + } + probes := make([]EmailAliasProbe, 0, len(domains)) + for _, candidate := range domains { + probes = append(probes, EmailAliasProbe{ + Local: probeLocal, + Domain: strings.ReplaceAll(candidate, ".", ""), + }) + } + return probes +} + +func splitEmailForAliasDedup(email string) (local string, domain string, ok bool) { + local, domain, ok = splitEmailForPolicy(email) + if !ok { + return "", "", false + } + domain = strings.TrimRight(domain, ".") + if domain == "" { + return "", "", false + } + return local, domain, true +} + +func stripEmailPlusSuffix(local string) string { + // idx > 0 only: "+tag@host" has no local part left to keep, and folding every + // "+x@host" into "@host" would lock unrelated senders out of that domain. + if idx := strings.IndexByte(local, '+'); idx > 0 { + return local[:idx] + } + return local +} + +func stripEmailLocalDots(local string) string { + if stripped := strings.ReplaceAll(local, ".", ""); stripped != "" { + return stripped + } + return local +} + +func isGmailFamilyDomain(domain string) bool { + _, ok := gmailFamilyDomains[domain] + return ok +} + +// existsByEmailOrAlias reports whether an email — or any alias variant that +// resolves to the same inbox — is already registered. +// +// It first performs the exact ExistsByEmail check, then, only on a miss, probes +// for an alias collision. Consistent with ExistsByEmail, lookup errors are +// surfaced (fail-closed) so the registration path returns a service error instead +// of letting an attacker bypass the check by inducing errors. +func (s *AuthService) existsByEmailOrAlias(ctx context.Context, email string) (bool, error) { + exists, err := s.userRepo.ExistsByEmail(ctx, email) + if err != nil || exists { + return exists, err + } + return s.userRepo.ExistsByEmailAlias(ctx, email) +} diff --git a/backend/internal/service/registration_email_alias_test.go b/backend/internal/service/registration_email_alias_test.go new file mode 100644 index 0000000000..75ef42902b --- /dev/null +++ b/backend/internal/service/registration_email_alias_test.go @@ -0,0 +1,164 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeEmailForAliasDedup(t *testing.T) { + cases := []struct { + name string + email string + want string + }{ + {"plain", "user@example.com", "user@example.com"}, + {"uppercase and spaces", " User@Example.COM ", "user@example.com"}, + {"plus alias stripped", "user+tag@example.com", "user@example.com"}, + {"gmail plus alias", "someone+bulk294@gmail.com", "someone@gmail.com"}, + {"gmail dots removed", "some.one@gmail.com", "someone@gmail.com"}, + {"gmail dots and plus", "s.o.m.e+x@gmail.com", "some@gmail.com"}, + {"googlemail folded to gmail", "user@googlemail.com", "user@gmail.com"}, + {"non-gmail keeps dots", "first.last@qq.com", "first.last@qq.com"}, + {"fqdn root dot dropped", "d.axis.2026@gmail.com.", "daxis2026@gmail.com"}, + {"fqdn root dot on other domain", "first.last@qq.com.", "first.last@qq.com"}, + {"leading plus keeps local part", "+alice@gmail.com", "+alice@gmail.com"}, + {"dot-only local part kept", "...@gmail.com", "...@gmail.com"}, + {"invalid keeps lowered raw", "not-an-email", "not-an-email"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, NormalizeEmailForAliasDedup(tc.email)) + }) + } +} + +func TestNormalizeEmailForAliasDedupKeepsDistinctInboxes(t *testing.T) { + // 剥离 "+后缀" 不能把同域下不同用户折叠成同一身份。 + require.NotEqual(t, + NormalizeEmailForAliasDedup("+alice@gmail.com"), + NormalizeEmailForAliasDedup("+bob@gmail.com"), + ) + require.NotEqual(t, + NormalizeEmailForAliasDedup("alice@gmail.com"), + NormalizeEmailForAliasDedup("bob@gmail.com"), + ) +} + +func TestEmailAliasDedupProbes(t *testing.T) { + require.ElementsMatch(t, + []EmailAliasProbe{{Local: "someone", Domain: "gmailcom"}, {Local: "someone", Domain: "googlemailcom"}}, + EmailAliasDedupProbes("Some.One+tag@gmail.com"), + ) + require.ElementsMatch(t, + []EmailAliasProbe{{Local: "daxis2026", Domain: "gmailcom"}, {Local: "daxis2026", Domain: "googlemailcom"}}, + EmailAliasDedupProbes("d.axis.2026@googlemail.com."), + ) + require.Equal(t, + []EmailAliasProbe{{Local: "firstlast", Domain: "qqcom"}}, + EmailAliasDedupProbes("first.last+tag@qq.com"), + ) + require.Nil(t, EmailAliasDedupProbes("not-an-email")) + require.Nil(t, EmailAliasDedupProbes("...@gmail.com")) +} + +// aliasDedupRepoStub implements only the methods alias dedup uses; other +// UserRepository methods come from the embedded nil interface (a wrong call +// would panic, failing the test). +type aliasDedupRepoStub struct { + UserRepository + exists bool + existsErr error + stored []string + aliasErr error + aliasChecks []string +} + +func (s *aliasDedupRepoStub) ExistsByEmail(context.Context, string) (bool, error) { + return s.exists, s.existsErr +} + +func (s *aliasDedupRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) { + s.aliasChecks = append(s.aliasChecks, email) + if s.aliasErr != nil { + return false, s.aliasErr + } + identity := NormalizeEmailForAliasDedup(email) + for _, candidate := range s.stored { + if NormalizeEmailForAliasDedup(candidate) == identity { + return true, nil + } + } + return false, nil +} + +func TestExistsByEmailOrAlias(t *testing.T) { + ctx := context.Background() + + t.Run("exact duplicate short-circuits", func(t *testing.T) { + repo := &aliasDedupRepoStub{exists: true} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.NoError(t, err) + require.True(t, got) + require.Empty(t, repo.aliasChecks, "no alias probe expected after exact hit") + }) + + t.Run("plus alias variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"someone+bulk294@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "Someone@gmail.com") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("gmail dot variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"some.one@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "someone@gmail.com") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("fqdn root dot variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"d.axis.2026@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "da.xis.2026@gmail.com.") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("different inbox allowed", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"other@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.NoError(t, err) + require.False(t, got) + }) + + t.Run("distinct plus-prefixed locals allowed", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"+alice@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "+bob@gmail.com") + require.NoError(t, err) + require.False(t, got) + }) + + t.Run("alias probe error fails closed", func(t *testing.T) { + repo := &aliasDedupRepoStub{aliasErr: errors.New("db down")} + svc := &AuthService{userRepo: repo} + _, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.Error(t, err) + }) + + t.Run("exact check error propagates", func(t *testing.T) { + repo := &aliasDedupRepoStub{existsErr: errors.New("db down")} + svc := &AuthService{userRepo: repo} + _, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.Error(t, err) + }) +} diff --git a/backend/internal/service/session_id.go b/backend/internal/service/session_id.go new file mode 100644 index 0000000000..8cf39f056b --- /dev/null +++ b/backend/internal/service/session_id.go @@ -0,0 +1,75 @@ +package service + +import ( + "strings" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +// maxPersistedSessionIDLength bounds the persisted client session identifier to the +// usage_logs.session_id column width (VARCHAR(255)). Longer values are rejected so +// distinct identifiers can never alias through truncation. +const maxPersistedSessionIDLength = 255 + +// clientSessionIDHeaders extends the OpenAI-compatible sticky-session signals with +// native protocol identifiers that are safe to persist but must not alter OpenAI +// scheduling behavior. +var clientSessionIDHeaders = append( + append([]string(nil), explicitOpenAIHeaderSessionNames...), + claudeCodeSessionHeader, +) + +// ExtractClientSessionID resolves the explicit client-provided session identifier from +// request headers for usage-log correlation and returns it sanitized. It is +// protocol-agnostic and shared by every gateway handler so all supported protocols +// record session_id through one seam. Returns "" when no valid identifier is present. +// +// This value feeds only usage_logs.session_id persistence. It does NOT affect sticky +// routing, account selection, request_id semantics, or upstream prompt caching, which +// keep their own (intentionally broader) session-signal resolution. +func ExtractClientSessionID(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + for _, header := range clientSessionIDHeaders { + if sessionID := sanitizeSessionID(c.GetHeader(header)); sessionID != "" { + return sessionID + } + } + if isGrokRequestContext(c) { + if sessionID := sanitizeSessionID(c.GetHeader(grokConversationIDHeader)); sessionID != "" { + return sessionID + } + } + return "" +} + +// sanitizeSessionID normalizes a raw client-supplied session identifier for safe +// persistence: it trims surrounding whitespace, rejects the value outright if it +// contains any control character (CR/LF/tab/NUL/…) so a log- or header-injection style +// payload cannot slip into stored correlation data, and rejects values longer than +// the DB column bound. Absent or invalid input yields "". +func sanitizeSessionID(raw string) string { + if !utf8.ValidString(raw) { + return "" + } + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + count := 0 + for _, r := range trimmed { + if r < 0x20 || r == 0x7f { + // An explicit correlation id never legitimately contains control + // characters; drop the whole value rather than persist a mangled or + // partially-injected identifier. + return "" + } + count++ + if count > maxPersistedSessionIDLength { + return "" + } + } + return trimmed +} diff --git a/backend/internal/service/session_id_test.go b/backend/internal/service/session_id_test.go new file mode 100644 index 0000000000..1eb746d6ef --- /dev/null +++ b/backend/internal/service/session_id_test.go @@ -0,0 +1,155 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newSessionHeaderContext(t *testing.T, headers map[string]string) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + for k, v := range headers { + req.Header.Set(k, v) + } + c.Request = req + return c +} + +func TestSanitizeSessionID(t *testing.T) { + longRunes := strings.Repeat("a", maxPersistedSessionIDLength+50) + multibyte := strings.Repeat("好", maxPersistedSessionIDLength+10) + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " \t ", ""}, + {"trims surrounding whitespace", " sess-123 ", "sess-123"}, + {"plain value", "conv_abc-123.XYZ", "conv_abc-123.XYZ"}, + {"uuid", "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000"}, + {"reject CR", "sess\r123", ""}, + {"reject LF", "sess\n123", ""}, + {"reject CRLF injection", "sess-1\r\nSet-Cookie: x=y", ""}, + {"reject tab inside", "sess\t123", ""}, + {"reject NUL", "sess\x00123", ""}, + {"reject DEL", "sess\x7f123", ""}, + {"reject invalid UTF-8", string([]byte{'s', 'e', 's', 's', '-', 0xff}), ""}, + {"accepts value at column bound", strings.Repeat("b", maxPersistedSessionIDLength), strings.Repeat("b", maxPersistedSessionIDLength)}, + {"rejects overlong value", longRunes, ""}, + {"rejects overlong multibyte value", multibyte, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeSessionID(tc.in) + require.Equal(t, tc.want, got, "sanitizeSessionID(%q)", tc.in) + // Sanitized output must never exceed the DB column bound (rune-counted). + require.LessOrEqual(t, len([]rune(got)), maxPersistedSessionIDLength) + }) + } +} + +func TestExtractClientSessionID_NilContext(t *testing.T) { + require.Equal(t, "", ExtractClientSessionID(nil)) +} + +func TestExtractClientSessionID_NilRequest(t *testing.T) { + require.Equal(t, "", ExtractClientSessionID(&gin.Context{})) +} + +func TestExtractClientSessionID_AbsentReturnsEmpty(t *testing.T) { + c := newSessionHeaderContext(t, nil) + require.Equal(t, "", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_SupportedHeaders(t *testing.T) { + tests := []struct { + name string + header string + value string + }{ + {"session_id", "session_id", "sess-A"}, + {"conversation_id", "conversation_id", "conv-B"}, + {"X-Session-Affinity", openCodeSessionAffinityHeader, "aff-C"}, + {"X-Session-Id", openCodeSessionIDHeader, "sid-D"}, + {"X-OpenCode-Session", openCodeNativeSessionHeader, "oc-E"}, + {"X-Conversation-ID", codeBuddyConversationHeader, "cb-F"}, + {"X-Claude-Code-Session-Id", claudeCodeSessionHeader, "cc-G"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := newSessionHeaderContext(t, map[string]string{tc.header: tc.value}) + require.Equal(t, tc.value, ExtractClientSessionID(c)) + }) + } +} + +func TestExtractClientSessionID_HeaderPrecedence(t *testing.T) { + // session_id ranks ahead of conversation_id and the X-* variants. + c := newSessionHeaderContext(t, map[string]string{ + "session_id": "primary", + "conversation_id": "secondary", + openCodeSessionIDHeader: "tertiary", + codeBuddyConversationHeader: "quaternary", + }) + require.Equal(t, "primary", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_Sanitizes(t *testing.T) { + c := newSessionHeaderContext(t, map[string]string{openCodeSessionIDHeader: " clean-123 "}) + require.Equal(t, "clean-123", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_IgnoresNonSessionHeaders(t *testing.T) { + // prompt_cache_key, request/message ids, and a Grok conversation header on a + // non-Grok request are NOT persisted as session_id. + c := newSessionHeaderContext(t, map[string]string{ + "prompt_cache_key": "cache-key-should-not-persist", + "X-Request-Id": "req-should-not-persist", + "x-grok-conv-id": "grok-conv-should-not-persist", + }) + require.Equal(t, "", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_GrokConversationHeader(t *testing.T) { + c := newSessionHeaderContext(t, map[string]string{ + grokConversationIDHeader: "grok-native-session", + }) + c.Set("api_key", &APIKey{ + ID: 42, + Group: &Group{Platform: PlatformGrok}, + }) + + require.Equal(t, "grok-native-session", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_GrokConversationHeaderForCompositeRoute(t *testing.T) { + c := newSessionHeaderContext(t, map[string]string{ + grokConversationIDHeader: "grok-composite-session", + }) + c.Set("api_key", &APIKey{ + ID: 43, + Group: &Group{Platform: PlatformComposite}, + }) + c.Request = c.Request.WithContext(WithResolvedTargetPlatform(context.Background(), PlatformGrok)) + + require.Equal(t, "grok-composite-session", ExtractClientSessionID(c)) +} + +func TestExtractClientSessionID_InjectionHeaderDropped(t *testing.T) { + // A supported header carrying a CRLF payload is rejected, not persisted mangled. + c := newSessionHeaderContext(t, map[string]string{"session_id": "abc"}) + c.Request.Header.Set("session_id", "abc\r\nX-Injected: 1") + require.Equal(t, "", ExtractClientSessionID(c)) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 4e8df04aaf..b35d149ea8 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -19,11 +19,12 @@ const ( RequestTypeStream RequestType = 2 RequestTypeWSV2 RequestType = 3 RequestTypeCyberBlocked RequestType = 4 // cyber_policy 命中(透传但被上游安全策略拒绝) + RequestTypeLive RequestType = 5 ) func (t RequestType) IsValid() bool { switch t { - case RequestTypeUnknown, RequestTypeSync, RequestTypeStream, RequestTypeWSV2, RequestTypeCyberBlocked: + case RequestTypeUnknown, RequestTypeSync, RequestTypeStream, RequestTypeWSV2, RequestTypeCyberBlocked, RequestTypeLive: return true default: return false @@ -47,6 +48,8 @@ func (t RequestType) String() string { return "ws_v2" case RequestTypeCyberBlocked: return "cyber" + case RequestTypeLive: + return "live" default: return "unknown" } @@ -68,8 +71,10 @@ func ParseUsageRequestType(value string) (RequestType, error) { return RequestTypeWSV2, nil case "cyber": return RequestTypeCyberBlocked, nil + case "live": + return RequestTypeLive, nil default: - return RequestTypeUnknown, fmt.Errorf("invalid request_type, allowed values: unknown, sync, stream, ws_v2, cyber") + return RequestTypeUnknown, fmt.Errorf("invalid request_type, allowed values: unknown, sync, stream, ws_v2, cyber, live") } } @@ -165,6 +170,10 @@ type UsageLog struct { FirstTokenMs *int UserAgent *string IPAddress *string + // SessionID is the explicit client-provided request correlation identifier + // (e.g. the session_id / X-Session-Id headers). Nil when the client sent no + // valid session header. It is never derived from prompt_cache_key or content. + SessionID *string // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) CacheTTLOverridden bool diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index ae630559fb..27c2db8edc 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -85,6 +85,11 @@ type UserListFilters struct { type UserRepository interface { Create(ctx context.Context, user *User) error + // CreateWithEmailAliasGuard 创建用户,并在邮箱唯一性锁内复查"收件箱身份"是否已被占用 + // (+别名 / Gmail 点号 / FQDN 根点变体,见 NormalizeEmailForAliasDedup), + // 冲突时返回 ErrEmailExists。仅注册路径使用:同一收件箱的多个别名变体并发注册时, + // 服务层的前置查重会同时通过,必须由这里串行化兜底。管理员建号仍走 Create,不受限制。 + CreateWithEmailAliasGuard(ctx context.Context, user *User) error GetByID(ctx context.Context, id int64) (*User, error) // GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。 GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) @@ -109,6 +114,9 @@ type UserRepository interface { 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) + // ExistsByEmailAlias 判断是否已有账号与该邮箱指向同一收件箱(+别名 / Gmail 点号 / + // FQDN 根点变体,见 NormalizeEmailForAliasDedup)。用于注册与发送验证码前的查重。 + ExistsByEmailAlias(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index e667377d55..adbb54ed3a 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -90,7 +90,8 @@ func (m *mockUserSettingRepo) Delete(context.Context, string) error { panic("unexpected Delete call") } -func (m *mockUserRepo) Create(context.Context, *User) error { return nil } +func (m *mockUserRepo) Create(context.Context, *User) error { return nil } +func (m *mockUserRepo) CreateWithEmailAliasGuard(context.Context, *User) error { return nil } func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) { if m.getByIDErr != nil { return nil, m.getByIDErr @@ -202,6 +203,9 @@ func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float } func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil } func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (m *mockUserRepo) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/migrations/187_add_usage_log_session_id.sql b/backend/migrations/187_add_usage_log_session_id.sql new file mode 100644 index 0000000000..7d6a4f6e21 --- /dev/null +++ b/backend/migrations/187_add_usage_log_session_id.sql @@ -0,0 +1,14 @@ +-- Persist the explicit client-provided request session/conversation identifier +-- (e.g. the session_id / X-Session-Id / X-Conversation-ID request headers) so +-- usage rows can be correlated by session for querying. +-- +-- Nullable with no default: on PostgreSQL 11+ this is a metadata-only change, so +-- it does NOT rewrite the (potentially large) usage_logs table. Absent/invalid +-- session identifiers stay NULL. Only explicit client correlation identity is +-- stored here; prompt_cache_key and content-derived sticky hashes are never +-- persisted as session_id. +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS session_id VARCHAR(255); + +-- Batch image usage is recorded asynchronously after the originating HTTP +-- request has finished, so retain the same sanitized identifier on the job. +ALTER TABLE batch_image_jobs ADD COLUMN IF NOT EXISTS session_id VARCHAR(255); diff --git a/backend/migrations/188_allow_live_usage_request_type.sql b/backend/migrations/188_allow_live_usage_request_type.sql new file mode 100644 index 0000000000..923315d01f --- /dev/null +++ b/backend/migrations/188_allow_live_usage_request_type.sql @@ -0,0 +1,6 @@ +ALTER TABLE usage_logs + DROP CONSTRAINT IF EXISTS usage_logs_request_type_check; + +ALTER TABLE usage_logs + ADD CONSTRAINT usage_logs_request_type_check + CHECK (request_type >= 0 AND request_type <= 5); diff --git a/backend/migrations/189_add_group_allow_live.sql b/backend/migrations/189_add_group_allow_live.sql new file mode 100644 index 0000000000..660fba2ceb --- /dev/null +++ b/backend/migrations/189_add_group_allow_live.sql @@ -0,0 +1 @@ +ALTER TABLE groups ADD COLUMN allow_live BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql b/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql new file mode 100644 index 0000000000..8606618c81 --- /dev/null +++ b/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql @@ -0,0 +1,8 @@ +-- Registration alias dedup (repository.existsByEmailAliasWithClient) probes users +-- by the dot-stripped email form, on the public register / send-verify-code paths. +-- Index that exact expression so the probes stay index lookups instead of a +-- sequential scan. text_pattern_ops serves both the equality probe and the +-- "local+%@domain" prefix probe regardless of database collation. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_dot_stripped + ON users ((REPLACE(LOWER(TRIM(email)), '.', '')) text_pattern_ops) + WHERE deleted_at IS NULL; diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index a762b2145f..8c3e95aabe 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -520,6 +520,42 @@ "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346 }, + "claude-opus-5": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "provider_specific_entry": { + "fast": 6.0, + "us": 1.1 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "tool_use_system_prompt_tokens": 346 + }, "claude-sonnet-4-20250514": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index f66224457a..535a599845 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -293,6 +293,9 @@ gateway: # Use this to avoid compact failures when newer models are not yet supported by the compact endpoint. # 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。 openai_compact_model: "gpt-5.4" + # ChatGPT Frameless Live 单会话硬上限(秒)。 + live: + max_session_duration_seconds: 3600 # OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) openai_ws: # 新版 WS mode 路由(默认关闭)。关闭时忽略账号级 WS mode(包括 http_bridge),保持 legacy ctx_pool 行为。 diff --git a/frontend/package.json b/frontend/package.json index b3c3450a39..1d4aaf3aef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,7 +62,8 @@ "pnpm": { "overrides": { "js-cookie": "3.0.7", - "form-data@<4.0.6": ">=4.0.6" + "form-data@<4.0.6": ">=4.0.6", + "postcss@<8.5.18": ">=8.5.18" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4bc3d2fee6..afd53708fa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: js-cookie: 3.0.7 form-data@<4.0.6: '>=4.0.6' + postcss@<8.5.18: '>=8.5.18' importers: @@ -105,7 +106,7 @@ importers: version: 2.4.6 autoprefixer: specifier: ^10.4.16 - version: 10.4.23(postcss@8.5.6) + version: 10.4.23(postcss@8.5.23) eslint: specifier: ^8.57.0 version: 8.57.1 @@ -116,8 +117,8 @@ importers: specifier: ^24.1.3 version: 24.1.3 postcss: - specifier: ^8.4.32 - version: 8.5.6 + specifier: '>=8.5.18' + version: 8.5.23 tailwindcss: specifier: ^3.4.0 version: 3.4.19 @@ -1876,7 +1877,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.1.0 + postcss: '>=8.5.18' axios@1.18.1: resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} @@ -3321,8 +3322,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3511,20 +3512,20 @@ packages: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: - postcss: ^8.0.0 + postcss: '>=8.5.18' postcss-js@4.1.0: resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: - postcss: ^8.4.21 + postcss: '>=8.5.18' postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: '>=8.5.18' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -3541,7 +3542,7 @@ packages: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: - postcss: ^8.2.14 + postcss: '>=8.5.18' postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} @@ -3550,8 +3551,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -6284,7 +6285,7 @@ snapshots: '@vue/shared': 3.5.26 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.6 + postcss: 8.5.23 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.26': @@ -6515,13 +6516,13 @@ snapshots: attr-accept@2.2.5: {} - autoprefixer@10.4.23(postcss@8.5.6): + autoprefixer@10.4.23(postcss@8.5.23): dependencies: browserslist: 4.28.1 caniuse-lite: 1.0.30001761 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.23 postcss-value-parser: 4.2.0 axios@1.18.1: @@ -8442,7 +8443,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.11: {} + nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -8607,28 +8608,28 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 - postcss-import@15.1.0(postcss@8.5.6): + postcss-import@15.1.0(postcss@8.5.23): dependencies: - postcss: 8.5.6 + postcss: 8.5.23 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - postcss-js@4.1.0(postcss@8.5.6): + postcss-js@4.1.0(postcss@8.5.23): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.6 + postcss: 8.5.23 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.5.23 - postcss-nested@6.2.0(postcss@8.5.6): + postcss-nested@6.2.0(postcss@8.5.23): dependencies: - postcss: 8.5.6 + postcss: 8.5.23 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -8638,9 +8639,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.6: + postcss@8.5.23: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9253,11 +9254,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) - postcss-nested: 6.2.0(postcss@8.5.6) + postcss: 8.5.23 + postcss-import: 15.1.0(postcss@8.5.23) + postcss-js: 4.1.0(postcss@8.5.23) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23) + postcss-nested: 6.2.0(postcss@8.5.23) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 @@ -9489,7 +9490,7 @@ snapshots: vite@5.4.21(@types/node@20.19.27): dependencies: esbuild: 0.21.5 - postcss: 8.5.6 + postcss: 8.5.23 rollup: 4.54.0 optionalDependencies: '@types/node': 20.19.27 diff --git a/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts b/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts index 1777819cb1..e60643a695 100644 --- a/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts +++ b/frontend/src/api/__tests__/admin.accounts.ollamaCloudUsage.spec.ts @@ -38,7 +38,7 @@ describe('admin Ollama Cloud usage API', () => { }) it('uses dedicated global settings endpoints', async () => { - const settings = { enabled: false, interval_minutes: 60 } + const settings = { enabled: false, interval_minutes: 60, debounce_minutes: 1 } get.mockResolvedValueOnce({ data: settings }) put.mockResolvedValueOnce({ data: settings }) diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 0e69be4994..3828bf9d71 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -16,6 +16,11 @@ import type { PaginatedResponse } from '@/types' +export interface LiveCapability { + supported: boolean + reason?: string +} + /** * List all groups with pagination * @param page - Page number (default: 1) @@ -81,6 +86,12 @@ export async function getByPlatform(platform: GroupPlatform): Promise { + const { data } = await apiClient.get('/admin/groups/live-capability') + return data +} + /** * Get group by ID * @param id - Group ID @@ -467,6 +478,7 @@ export const groupsAPI = { getAll, getByPlatform, getAllIncludingInactive, + getLiveCapability, getById, getModelsListCandidates, create, diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 7d19b784a2..d8ff410ddc 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -225,6 +225,7 @@ const formatScopeName = (scope: string): string => { 'claude-opus-4-6-thinking': 'COpus46T', 'claude-opus-4-7': 'COpus47', 'claude-opus-4-8': 'COpus48', + 'claude-opus-5': 'COpus5', 'claude-sonnet-4-6': 'CSon46', 'claude-sonnet-4-5': 'CSon45', 'claude-sonnet-4-5-thinking': 'CSon45T', diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 7ac50aa6b2..6a5fad6269 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -263,6 +263,7 @@ const groupOptions = ref([{ value: null, label: t('admin.usage.a const requestTypeOptions = ref([ { value: null, label: t('admin.usage.allTypes') }, { value: 'ws_v2', label: t('usage.ws') }, + { value: 'live', label: t('usage.live') }, { value: 'stream', label: t('usage.stream') }, { value: 'sync', label: t('usage.sync') }, { value: 'cyber', label: t('usage.cyber') } diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index ea69fefb2f..032ecc0bd2 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -583,6 +583,7 @@ const tokenTooltipData = ref(null) const getRequestTypeLabel = (row: AdminUsageLog): string => { const requestType = resolveUsageRequestType(row) if (requestType === 'cyber') return t('usage.cyber') + if (requestType === 'live') return t('usage.live') if (requestType === 'ws_v2') return t('usage.ws') if (requestType === 'stream') return t('usage.stream') if (requestType === 'sync') return t('usage.sync') @@ -592,6 +593,7 @@ const getRequestTypeLabel = (row: AdminUsageLog): string => { const getRequestTypeBadgeClass = (row: AdminUsageLog): string => { const requestType = resolveUsageRequestType(row) if (requestType === 'cyber') return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' + if (requestType === 'live') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' if (requestType === 'ws_v2') return 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200' if (requestType === 'stream') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' if (requestType === 'sync') return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' diff --git a/frontend/src/components/common/AnnouncementBell.vue b/frontend/src/components/common/AnnouncementBell.vue index cfbd62a0b8..e80e449f28 100644 --- a/frontend/src/components/common/AnnouncementBell.vue +++ b/frontend/src/components/common/AnnouncementBell.vue @@ -322,6 +322,7 @@ import { useAnnouncementStore } from '@/stores/announcements' import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format' import type { UserAnnouncement } from '@/types' import Icon from '@/components/icons/Icon.vue' +import '@/styles/announcement-markdown.css' const { t } = useI18n() const appStore = useAppStore() @@ -471,120 +472,3 @@ watch( background: linear-gradient(to bottom, #6b7280, #4b5563); } - - diff --git a/frontend/src/components/common/AnnouncementPopup.vue b/frontend/src/components/common/AnnouncementPopup.vue index e26d9ee30b..99e10ed461 100644 --- a/frontend/src/components/common/AnnouncementPopup.vue +++ b/frontend/src/components/common/AnnouncementPopup.vue @@ -2,7 +2,7 @@

- {{ announcementStore.currentPopup.title }} + {{ displayedAnnouncement.title }}

@@ -43,7 +43,7 @@ - +
@@ -66,13 +66,17 @@
@@ -84,15 +88,34 @@