mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 17:14:45 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
074bd0dfda | ||
|
|
b41fa5e15f | ||
|
|
beceb45d23 | ||
|
|
9450edf462 | ||
|
|
785a7397f8 | ||
|
|
3d1f03c286 | ||
|
|
8ff40f52e0 | ||
|
|
a817cafe3d | ||
|
|
ab14df043a | ||
|
|
2857fa2ef7 | ||
|
|
e681431454 | ||
|
|
5b568aa9d4 | ||
|
|
471943269c | ||
|
|
28a5e2f0e6 | ||
|
|
b4c22ce6ce | ||
|
|
5248097f90 | ||
|
|
8e2c22d0bd | ||
|
|
be56a282f2 | ||
|
|
5f4eb9f9d0 | ||
|
|
d1cd5c0a73 | ||
|
|
5429c74c10 | ||
|
|
fe1d46a8ea | ||
|
|
c7b42148a5 | ||
|
|
bc1abb6a23 | ||
|
|
d307d48def | ||
|
|
1bb40084fc | ||
|
|
8f0efa16ca | ||
|
|
ef2c35dbb1 | ||
|
|
04a1a7c2b5 | ||
|
|
d21d70a5cf | ||
|
|
e73b778d2b | ||
|
|
6ae82e04d5 | ||
|
|
19cca11e00 | ||
|
|
c8f87a9c92 |
2
.github/workflows/security-scan.yml
vendored
2
.github/workflows/security-scan.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: |
|
run: |
|
||||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||||
gosec -severity high -confidence high ./...
|
gosec -conf .gosec.json -severity high -confidence high ./...
|
||||||
|
|
||||||
frontend-security:
|
frontend-security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
5
backend/.gosec.json
Normal file
5
backend/.gosec.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"exclude": "G704"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
0.1.76
|
0.1.83
|
||||||
@@ -650,6 +650,7 @@ var (
|
|||||||
{Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45},
|
{Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45},
|
||||||
{Name: "image_count", Type: field.TypeInt, Default: 0},
|
{Name: "image_count", Type: field.TypeInt, Default: 0},
|
||||||
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
|
{Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10},
|
||||||
|
{Name: "cache_ttl_overridden", Type: field.TypeBool, Default: false},
|
||||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||||
{Name: "api_key_id", Type: field.TypeInt64},
|
{Name: "api_key_id", Type: field.TypeInt64},
|
||||||
{Name: "account_id", Type: field.TypeInt64},
|
{Name: "account_id", Type: field.TypeInt64},
|
||||||
@@ -665,31 +666,31 @@ var (
|
|||||||
ForeignKeys: []*schema.ForeignKey{
|
ForeignKeys: []*schema.ForeignKey{
|
||||||
{
|
{
|
||||||
Symbol: "usage_logs_api_keys_usage_logs",
|
Symbol: "usage_logs_api_keys_usage_logs",
|
||||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.NoAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Symbol: "usage_logs_accounts_usage_logs",
|
Symbol: "usage_logs_accounts_usage_logs",
|
||||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
Columns: []*schema.Column{UsageLogsColumns[28]},
|
||||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.NoAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Symbol: "usage_logs_groups_usage_logs",
|
Symbol: "usage_logs_groups_usage_logs",
|
||||||
Columns: []*schema.Column{UsageLogsColumns[28]},
|
Columns: []*schema.Column{UsageLogsColumns[29]},
|
||||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||||
OnDelete: schema.SetNull,
|
OnDelete: schema.SetNull,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Symbol: "usage_logs_users_usage_logs",
|
Symbol: "usage_logs_users_usage_logs",
|
||||||
Columns: []*schema.Column{UsageLogsColumns[29]},
|
Columns: []*schema.Column{UsageLogsColumns[30]},
|
||||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.NoAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||||
Columns: []*schema.Column{UsageLogsColumns[30]},
|
Columns: []*schema.Column{UsageLogsColumns[31]},
|
||||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||||
OnDelete: schema.SetNull,
|
OnDelete: schema.SetNull,
|
||||||
},
|
},
|
||||||
@@ -698,32 +699,32 @@ var (
|
|||||||
{
|
{
|
||||||
Name: "usagelog_user_id",
|
Name: "usagelog_user_id",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[29]},
|
Columns: []*schema.Column{UsageLogsColumns[30]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_api_key_id",
|
Name: "usagelog_api_key_id",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[26]},
|
Columns: []*schema.Column{UsageLogsColumns[27]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_account_id",
|
Name: "usagelog_account_id",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[27]},
|
Columns: []*schema.Column{UsageLogsColumns[28]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_group_id",
|
Name: "usagelog_group_id",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[28]},
|
Columns: []*schema.Column{UsageLogsColumns[29]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_subscription_id",
|
Name: "usagelog_subscription_id",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[30]},
|
Columns: []*schema.Column{UsageLogsColumns[31]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_created_at",
|
Name: "usagelog_created_at",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[25]},
|
Columns: []*schema.Column{UsageLogsColumns[26]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_model",
|
Name: "usagelog_model",
|
||||||
@@ -738,12 +739,12 @@ var (
|
|||||||
{
|
{
|
||||||
Name: "usagelog_user_id_created_at",
|
Name: "usagelog_user_id_created_at",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[29], UsageLogsColumns[25]},
|
Columns: []*schema.Column{UsageLogsColumns[30], UsageLogsColumns[26]},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "usagelog_api_key_id_created_at",
|
Name: "usagelog_api_key_id_created_at",
|
||||||
Unique: false,
|
Unique: false,
|
||||||
Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[25]},
|
Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[26]},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15061,6 +15061,7 @@ type UsageLogMutation struct {
|
|||||||
image_count *int
|
image_count *int
|
||||||
addimage_count *int
|
addimage_count *int
|
||||||
image_size *string
|
image_size *string
|
||||||
|
cache_ttl_overridden *bool
|
||||||
created_at *time.Time
|
created_at *time.Time
|
||||||
clearedFields map[string]struct{}
|
clearedFields map[string]struct{}
|
||||||
user *int64
|
user *int64
|
||||||
@@ -16687,6 +16688,42 @@ func (m *UsageLogMutation) ResetImageSize() {
|
|||||||
delete(m.clearedFields, usagelog.FieldImageSize)
|
delete(m.clearedFields, usagelog.FieldImageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (m *UsageLogMutation) SetCacheTTLOverridden(b bool) {
|
||||||
|
m.cache_ttl_overridden = &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheTTLOverridden returns the value of the "cache_ttl_overridden" field in the mutation.
|
||||||
|
func (m *UsageLogMutation) CacheTTLOverridden() (r bool, exists bool) {
|
||||||
|
v := m.cache_ttl_overridden
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return *v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OldCacheTTLOverridden returns the old "cache_ttl_overridden" field's value of the UsageLog entity.
|
||||||
|
// If the UsageLog 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 *UsageLogMutation) OldCacheTTLOverridden(ctx context.Context) (v bool, err error) {
|
||||||
|
if !m.op.Is(OpUpdateOne) {
|
||||||
|
return v, errors.New("OldCacheTTLOverridden is only allowed on UpdateOne operations")
|
||||||
|
}
|
||||||
|
if m.id == nil || m.oldValue == nil {
|
||||||
|
return v, errors.New("OldCacheTTLOverridden requires an ID field in the mutation")
|
||||||
|
}
|
||||||
|
oldValue, err := m.oldValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return v, fmt.Errorf("querying old value for OldCacheTTLOverridden: %w", err)
|
||||||
|
}
|
||||||
|
return oldValue.CacheTTLOverridden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetCacheTTLOverridden resets all changes to the "cache_ttl_overridden" field.
|
||||||
|
func (m *UsageLogMutation) ResetCacheTTLOverridden() {
|
||||||
|
m.cache_ttl_overridden = nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetCreatedAt sets the "created_at" field.
|
// SetCreatedAt sets the "created_at" field.
|
||||||
func (m *UsageLogMutation) SetCreatedAt(t time.Time) {
|
func (m *UsageLogMutation) SetCreatedAt(t time.Time) {
|
||||||
m.created_at = &t
|
m.created_at = &t
|
||||||
@@ -16892,7 +16929,7 @@ func (m *UsageLogMutation) Type() string {
|
|||||||
// order to get all numeric fields that were incremented/decremented, call
|
// order to get all numeric fields that were incremented/decremented, call
|
||||||
// AddedFields().
|
// AddedFields().
|
||||||
func (m *UsageLogMutation) Fields() []string {
|
func (m *UsageLogMutation) Fields() []string {
|
||||||
fields := make([]string, 0, 30)
|
fields := make([]string, 0, 31)
|
||||||
if m.user != nil {
|
if m.user != nil {
|
||||||
fields = append(fields, usagelog.FieldUserID)
|
fields = append(fields, usagelog.FieldUserID)
|
||||||
}
|
}
|
||||||
@@ -16980,6 +17017,9 @@ func (m *UsageLogMutation) Fields() []string {
|
|||||||
if m.image_size != nil {
|
if m.image_size != nil {
|
||||||
fields = append(fields, usagelog.FieldImageSize)
|
fields = append(fields, usagelog.FieldImageSize)
|
||||||
}
|
}
|
||||||
|
if m.cache_ttl_overridden != nil {
|
||||||
|
fields = append(fields, usagelog.FieldCacheTTLOverridden)
|
||||||
|
}
|
||||||
if m.created_at != nil {
|
if m.created_at != nil {
|
||||||
fields = append(fields, usagelog.FieldCreatedAt)
|
fields = append(fields, usagelog.FieldCreatedAt)
|
||||||
}
|
}
|
||||||
@@ -17049,6 +17089,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
|||||||
return m.ImageCount()
|
return m.ImageCount()
|
||||||
case usagelog.FieldImageSize:
|
case usagelog.FieldImageSize:
|
||||||
return m.ImageSize()
|
return m.ImageSize()
|
||||||
|
case usagelog.FieldCacheTTLOverridden:
|
||||||
|
return m.CacheTTLOverridden()
|
||||||
case usagelog.FieldCreatedAt:
|
case usagelog.FieldCreatedAt:
|
||||||
return m.CreatedAt()
|
return m.CreatedAt()
|
||||||
}
|
}
|
||||||
@@ -17118,6 +17160,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
|||||||
return m.OldImageCount(ctx)
|
return m.OldImageCount(ctx)
|
||||||
case usagelog.FieldImageSize:
|
case usagelog.FieldImageSize:
|
||||||
return m.OldImageSize(ctx)
|
return m.OldImageSize(ctx)
|
||||||
|
case usagelog.FieldCacheTTLOverridden:
|
||||||
|
return m.OldCacheTTLOverridden(ctx)
|
||||||
case usagelog.FieldCreatedAt:
|
case usagelog.FieldCreatedAt:
|
||||||
return m.OldCreatedAt(ctx)
|
return m.OldCreatedAt(ctx)
|
||||||
}
|
}
|
||||||
@@ -17332,6 +17376,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
|||||||
}
|
}
|
||||||
m.SetImageSize(v)
|
m.SetImageSize(v)
|
||||||
return nil
|
return nil
|
||||||
|
case usagelog.FieldCacheTTLOverridden:
|
||||||
|
v, ok := value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||||
|
}
|
||||||
|
m.SetCacheTTLOverridden(v)
|
||||||
|
return nil
|
||||||
case usagelog.FieldCreatedAt:
|
case usagelog.FieldCreatedAt:
|
||||||
v, ok := value.(time.Time)
|
v, ok := value.(time.Time)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -17745,6 +17796,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
|||||||
case usagelog.FieldImageSize:
|
case usagelog.FieldImageSize:
|
||||||
m.ResetImageSize()
|
m.ResetImageSize()
|
||||||
return nil
|
return nil
|
||||||
|
case usagelog.FieldCacheTTLOverridden:
|
||||||
|
m.ResetCacheTTLOverridden()
|
||||||
|
return nil
|
||||||
case usagelog.FieldCreatedAt:
|
case usagelog.FieldCreatedAt:
|
||||||
m.ResetCreatedAt()
|
m.ResetCreatedAt()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -779,8 +779,12 @@ func init() {
|
|||||||
usagelogDescImageSize := usagelogFields[28].Descriptor()
|
usagelogDescImageSize := usagelogFields[28].Descriptor()
|
||||||
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||||
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
||||||
|
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
|
||||||
|
usagelogDescCacheTTLOverridden := usagelogFields[29].Descriptor()
|
||||||
|
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
|
||||||
|
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
|
||||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||||
usagelogDescCreatedAt := usagelogFields[29].Descriptor()
|
usagelogDescCreatedAt := usagelogFields[30].Descriptor()
|
||||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||||
userMixin := schema.User{}.Mixin()
|
userMixin := schema.User{}.Mixin()
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ func (UsageLog) Fields() []ent.Field {
|
|||||||
Optional().
|
Optional().
|
||||||
Nillable(),
|
Nillable(),
|
||||||
|
|
||||||
|
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
|
||||||
|
field.Bool("cache_ttl_overridden").
|
||||||
|
Default(false),
|
||||||
|
|
||||||
// 时间戳(只有 created_at,日志不可修改)
|
// 时间戳(只有 created_at,日志不可修改)
|
||||||
field.Time("created_at").
|
field.Time("created_at").
|
||||||
Default(time.Now).
|
Default(time.Now).
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ type UsageLog struct {
|
|||||||
ImageCount int `json:"image_count,omitempty"`
|
ImageCount int `json:"image_count,omitempty"`
|
||||||
// ImageSize holds the value of the "image_size" field.
|
// ImageSize holds the value of the "image_size" field.
|
||||||
ImageSize *string `json:"image_size,omitempty"`
|
ImageSize *string `json:"image_size,omitempty"`
|
||||||
|
// CacheTTLOverridden holds the value of the "cache_ttl_overridden" field.
|
||||||
|
CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"`
|
||||||
// CreatedAt holds the value of the "created_at" field.
|
// CreatedAt holds the value of the "created_at" field.
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
// Edges holds the relations/edges for other nodes in the graph.
|
// Edges holds the relations/edges for other nodes in the graph.
|
||||||
@@ -165,7 +167,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
|
|||||||
values := make([]any, len(columns))
|
values := make([]any, len(columns))
|
||||||
for i := range columns {
|
for i := range columns {
|
||||||
switch columns[i] {
|
switch columns[i] {
|
||||||
case usagelog.FieldStream:
|
case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||||
values[i] = new(sql.NullBool)
|
values[i] = new(sql.NullBool)
|
||||||
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
||||||
values[i] = new(sql.NullFloat64)
|
values[i] = new(sql.NullFloat64)
|
||||||
@@ -378,6 +380,12 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
|||||||
_m.ImageSize = new(string)
|
_m.ImageSize = new(string)
|
||||||
*_m.ImageSize = value.String
|
*_m.ImageSize = value.String
|
||||||
}
|
}
|
||||||
|
case usagelog.FieldCacheTTLOverridden:
|
||||||
|
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||||
|
return fmt.Errorf("unexpected type %T for field cache_ttl_overridden", values[i])
|
||||||
|
} else if value.Valid {
|
||||||
|
_m.CacheTTLOverridden = value.Bool
|
||||||
|
}
|
||||||
case usagelog.FieldCreatedAt:
|
case usagelog.FieldCreatedAt:
|
||||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||||
return fmt.Errorf("unexpected type %T for field created_at", values[i])
|
return fmt.Errorf("unexpected type %T for field created_at", values[i])
|
||||||
@@ -548,6 +556,9 @@ func (_m *UsageLog) String() string {
|
|||||||
builder.WriteString(*v)
|
builder.WriteString(*v)
|
||||||
}
|
}
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
|
builder.WriteString("cache_ttl_overridden=")
|
||||||
|
builder.WriteString(fmt.Sprintf("%v", _m.CacheTTLOverridden))
|
||||||
|
builder.WriteString(", ")
|
||||||
builder.WriteString("created_at=")
|
builder.WriteString("created_at=")
|
||||||
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
|
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
|
||||||
builder.WriteByte(')')
|
builder.WriteByte(')')
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ const (
|
|||||||
FieldImageCount = "image_count"
|
FieldImageCount = "image_count"
|
||||||
// FieldImageSize holds the string denoting the image_size field in the database.
|
// FieldImageSize holds the string denoting the image_size field in the database.
|
||||||
FieldImageSize = "image_size"
|
FieldImageSize = "image_size"
|
||||||
|
// FieldCacheTTLOverridden holds the string denoting the cache_ttl_overridden field in the database.
|
||||||
|
FieldCacheTTLOverridden = "cache_ttl_overridden"
|
||||||
// FieldCreatedAt holds the string denoting the created_at field in the database.
|
// FieldCreatedAt holds the string denoting the created_at field in the database.
|
||||||
FieldCreatedAt = "created_at"
|
FieldCreatedAt = "created_at"
|
||||||
// EdgeUser holds the string denoting the user edge name in mutations.
|
// EdgeUser holds the string denoting the user edge name in mutations.
|
||||||
@@ -155,6 +157,7 @@ var Columns = []string{
|
|||||||
FieldIPAddress,
|
FieldIPAddress,
|
||||||
FieldImageCount,
|
FieldImageCount,
|
||||||
FieldImageSize,
|
FieldImageSize,
|
||||||
|
FieldCacheTTLOverridden,
|
||||||
FieldCreatedAt,
|
FieldCreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +214,8 @@ var (
|
|||||||
DefaultImageCount int
|
DefaultImageCount int
|
||||||
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
// ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||||
ImageSizeValidator func(string) error
|
ImageSizeValidator func(string) error
|
||||||
|
// DefaultCacheTTLOverridden holds the default value on creation for the "cache_ttl_overridden" field.
|
||||||
|
DefaultCacheTTLOverridden bool
|
||||||
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
|
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
|
||||||
DefaultCreatedAt func() time.Time
|
DefaultCreatedAt func() time.Time
|
||||||
)
|
)
|
||||||
@@ -368,6 +373,11 @@ func ByImageSize(opts ...sql.OrderTermOption) OrderOption {
|
|||||||
return sql.OrderByField(FieldImageSize, opts...).ToFunc()
|
return sql.OrderByField(FieldImageSize, opts...).ToFunc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ByCacheTTLOverridden orders the results by the cache_ttl_overridden field.
|
||||||
|
func ByCacheTTLOverridden(opts ...sql.OrderTermOption) OrderOption {
|
||||||
|
return sql.OrderByField(FieldCacheTTLOverridden, opts...).ToFunc()
|
||||||
|
}
|
||||||
|
|
||||||
// ByCreatedAt orders the results by the created_at field.
|
// ByCreatedAt orders the results by the created_at field.
|
||||||
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
|
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||||
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
|
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ func ImageSize(v string) predicate.UsageLog {
|
|||||||
return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v))
|
return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheTTLOverridden applies equality check predicate on the "cache_ttl_overridden" field. It's identical to CacheTTLOverriddenEQ.
|
||||||
|
func CacheTTLOverridden(v bool) predicate.UsageLog {
|
||||||
|
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
|
||||||
|
}
|
||||||
|
|
||||||
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
|
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
|
||||||
func CreatedAt(v time.Time) predicate.UsageLog {
|
func CreatedAt(v time.Time) predicate.UsageLog {
|
||||||
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))
|
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))
|
||||||
@@ -1440,6 +1445,16 @@ func ImageSizeContainsFold(v string) predicate.UsageLog {
|
|||||||
return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v))
|
return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheTTLOverriddenEQ applies the EQ predicate on the "cache_ttl_overridden" field.
|
||||||
|
func CacheTTLOverriddenEQ(v bool) predicate.UsageLog {
|
||||||
|
return predicate.UsageLog(sql.FieldEQ(FieldCacheTTLOverridden, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheTTLOverriddenNEQ applies the NEQ predicate on the "cache_ttl_overridden" field.
|
||||||
|
func CacheTTLOverriddenNEQ(v bool) predicate.UsageLog {
|
||||||
|
return predicate.UsageLog(sql.FieldNEQ(FieldCacheTTLOverridden, v))
|
||||||
|
}
|
||||||
|
|
||||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||||
func CreatedAtEQ(v time.Time) predicate.UsageLog {
|
func CreatedAtEQ(v time.Time) predicate.UsageLog {
|
||||||
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))
|
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))
|
||||||
|
|||||||
@@ -393,6 +393,20 @@ func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate {
|
|||||||
return _c
|
return _c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (_c *UsageLogCreate) SetCacheTTLOverridden(v bool) *UsageLogCreate {
|
||||||
|
_c.mutation.SetCacheTTLOverridden(v)
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNillableCacheTTLOverridden sets the "cache_ttl_overridden" field if the given value is not nil.
|
||||||
|
func (_c *UsageLogCreate) SetNillableCacheTTLOverridden(v *bool) *UsageLogCreate {
|
||||||
|
if v != nil {
|
||||||
|
_c.SetCacheTTLOverridden(*v)
|
||||||
|
}
|
||||||
|
return _c
|
||||||
|
}
|
||||||
|
|
||||||
// SetCreatedAt sets the "created_at" field.
|
// SetCreatedAt sets the "created_at" field.
|
||||||
func (_c *UsageLogCreate) SetCreatedAt(v time.Time) *UsageLogCreate {
|
func (_c *UsageLogCreate) SetCreatedAt(v time.Time) *UsageLogCreate {
|
||||||
_c.mutation.SetCreatedAt(v)
|
_c.mutation.SetCreatedAt(v)
|
||||||
@@ -531,6 +545,10 @@ func (_c *UsageLogCreate) defaults() {
|
|||||||
v := usagelog.DefaultImageCount
|
v := usagelog.DefaultImageCount
|
||||||
_c.mutation.SetImageCount(v)
|
_c.mutation.SetImageCount(v)
|
||||||
}
|
}
|
||||||
|
if _, ok := _c.mutation.CacheTTLOverridden(); !ok {
|
||||||
|
v := usagelog.DefaultCacheTTLOverridden
|
||||||
|
_c.mutation.SetCacheTTLOverridden(v)
|
||||||
|
}
|
||||||
if _, ok := _c.mutation.CreatedAt(); !ok {
|
if _, ok := _c.mutation.CreatedAt(); !ok {
|
||||||
v := usagelog.DefaultCreatedAt()
|
v := usagelog.DefaultCreatedAt()
|
||||||
_c.mutation.SetCreatedAt(v)
|
_c.mutation.SetCreatedAt(v)
|
||||||
@@ -627,6 +645,9 @@ func (_c *UsageLogCreate) check() error {
|
|||||||
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
|
return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, ok := _c.mutation.CacheTTLOverridden(); !ok {
|
||||||
|
return &ValidationError{Name: "cache_ttl_overridden", err: errors.New(`ent: missing required field "UsageLog.cache_ttl_overridden"`)}
|
||||||
|
}
|
||||||
if _, ok := _c.mutation.CreatedAt(); !ok {
|
if _, ok := _c.mutation.CreatedAt(); !ok {
|
||||||
return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "UsageLog.created_at"`)}
|
return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "UsageLog.created_at"`)}
|
||||||
}
|
}
|
||||||
@@ -762,6 +783,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
|
|||||||
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
|
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
|
||||||
_node.ImageSize = &value
|
_node.ImageSize = &value
|
||||||
}
|
}
|
||||||
|
if value, ok := _c.mutation.CacheTTLOverridden(); ok {
|
||||||
|
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||||
|
_node.CacheTTLOverridden = value
|
||||||
|
}
|
||||||
if value, ok := _c.mutation.CreatedAt(); ok {
|
if value, ok := _c.mutation.CreatedAt(); ok {
|
||||||
_spec.SetField(usagelog.FieldCreatedAt, field.TypeTime, value)
|
_spec.SetField(usagelog.FieldCreatedAt, field.TypeTime, value)
|
||||||
_node.CreatedAt = value
|
_node.CreatedAt = value
|
||||||
@@ -1407,6 +1432,18 @@ func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (u *UsageLogUpsert) SetCacheTTLOverridden(v bool) *UsageLogUpsert {
|
||||||
|
u.Set(usagelog.FieldCacheTTLOverridden, v)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCacheTTLOverridden sets the "cache_ttl_overridden" field to the value that was provided on create.
|
||||||
|
func (u *UsageLogUpsert) UpdateCacheTTLOverridden() *UsageLogUpsert {
|
||||||
|
u.SetExcluded(usagelog.FieldCacheTTLOverridden)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||||
// Using this option is equivalent to using:
|
// Using this option is equivalent to using:
|
||||||
//
|
//
|
||||||
@@ -2040,6 +2077,20 @@ func (u *UsageLogUpsertOne) ClearImageSize() *UsageLogUpsertOne {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (u *UsageLogUpsertOne) SetCacheTTLOverridden(v bool) *UsageLogUpsertOne {
|
||||||
|
return u.Update(func(s *UsageLogUpsert) {
|
||||||
|
s.SetCacheTTLOverridden(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCacheTTLOverridden sets the "cache_ttl_overridden" field to the value that was provided on create.
|
||||||
|
func (u *UsageLogUpsertOne) UpdateCacheTTLOverridden() *UsageLogUpsertOne {
|
||||||
|
return u.Update(func(s *UsageLogUpsert) {
|
||||||
|
s.UpdateCacheTTLOverridden()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Exec executes the query.
|
// Exec executes the query.
|
||||||
func (u *UsageLogUpsertOne) Exec(ctx context.Context) error {
|
func (u *UsageLogUpsertOne) Exec(ctx context.Context) error {
|
||||||
if len(u.create.conflict) == 0 {
|
if len(u.create.conflict) == 0 {
|
||||||
@@ -2839,6 +2890,20 @@ func (u *UsageLogUpsertBulk) ClearImageSize() *UsageLogUpsertBulk {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (u *UsageLogUpsertBulk) SetCacheTTLOverridden(v bool) *UsageLogUpsertBulk {
|
||||||
|
return u.Update(func(s *UsageLogUpsert) {
|
||||||
|
s.SetCacheTTLOverridden(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCacheTTLOverridden sets the "cache_ttl_overridden" field to the value that was provided on create.
|
||||||
|
func (u *UsageLogUpsertBulk) UpdateCacheTTLOverridden() *UsageLogUpsertBulk {
|
||||||
|
return u.Update(func(s *UsageLogUpsert) {
|
||||||
|
s.UpdateCacheTTLOverridden()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Exec executes the query.
|
// Exec executes the query.
|
||||||
func (u *UsageLogUpsertBulk) Exec(ctx context.Context) error {
|
func (u *UsageLogUpsertBulk) Exec(ctx context.Context) error {
|
||||||
if u.create.err != nil {
|
if u.create.err != nil {
|
||||||
|
|||||||
@@ -612,6 +612,20 @@ func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate {
|
|||||||
return _u
|
return _u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (_u *UsageLogUpdate) SetCacheTTLOverridden(v bool) *UsageLogUpdate {
|
||||||
|
_u.mutation.SetCacheTTLOverridden(v)
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNillableCacheTTLOverridden sets the "cache_ttl_overridden" field if the given value is not nil.
|
||||||
|
func (_u *UsageLogUpdate) SetNillableCacheTTLOverridden(v *bool) *UsageLogUpdate {
|
||||||
|
if v != nil {
|
||||||
|
_u.SetCacheTTLOverridden(*v)
|
||||||
|
}
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
// SetUser sets the "user" edge to the User entity.
|
// SetUser sets the "user" edge to the User entity.
|
||||||
func (_u *UsageLogUpdate) SetUser(v *User) *UsageLogUpdate {
|
func (_u *UsageLogUpdate) SetUser(v *User) *UsageLogUpdate {
|
||||||
return _u.SetUserID(v.ID)
|
return _u.SetUserID(v.ID)
|
||||||
@@ -894,6 +908,9 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
|||||||
if _u.mutation.ImageSizeCleared() {
|
if _u.mutation.ImageSizeCleared() {
|
||||||
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
|
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
|
||||||
}
|
}
|
||||||
|
if value, ok := _u.mutation.CacheTTLOverridden(); ok {
|
||||||
|
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||||
|
}
|
||||||
if _u.mutation.UserCleared() {
|
if _u.mutation.UserCleared() {
|
||||||
edge := &sqlgraph.EdgeSpec{
|
edge := &sqlgraph.EdgeSpec{
|
||||||
Rel: sqlgraph.M2O,
|
Rel: sqlgraph.M2O,
|
||||||
@@ -1639,6 +1656,20 @@ func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne {
|
|||||||
return _u
|
return _u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheTTLOverridden sets the "cache_ttl_overridden" field.
|
||||||
|
func (_u *UsageLogUpdateOne) SetCacheTTLOverridden(v bool) *UsageLogUpdateOne {
|
||||||
|
_u.mutation.SetCacheTTLOverridden(v)
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNillableCacheTTLOverridden sets the "cache_ttl_overridden" field if the given value is not nil.
|
||||||
|
func (_u *UsageLogUpdateOne) SetNillableCacheTTLOverridden(v *bool) *UsageLogUpdateOne {
|
||||||
|
if v != nil {
|
||||||
|
_u.SetCacheTTLOverridden(*v)
|
||||||
|
}
|
||||||
|
return _u
|
||||||
|
}
|
||||||
|
|
||||||
// SetUser sets the "user" edge to the User entity.
|
// SetUser sets the "user" edge to the User entity.
|
||||||
func (_u *UsageLogUpdateOne) SetUser(v *User) *UsageLogUpdateOne {
|
func (_u *UsageLogUpdateOne) SetUser(v *User) *UsageLogUpdateOne {
|
||||||
return _u.SetUserID(v.ID)
|
return _u.SetUserID(v.ID)
|
||||||
@@ -1951,6 +1982,9 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
|
|||||||
if _u.mutation.ImageSizeCleared() {
|
if _u.mutation.ImageSizeCleared() {
|
||||||
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
|
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
|
||||||
}
|
}
|
||||||
|
if value, ok := _u.mutation.CacheTTLOverridden(); ok {
|
||||||
|
_spec.SetField(usagelog.FieldCacheTTLOverridden, field.TypeBool, value)
|
||||||
|
}
|
||||||
if _u.mutation.UserCleared() {
|
if _u.mutation.UserCleared() {
|
||||||
edge := &sqlgraph.EdgeSpec{
|
edge := &sqlgraph.EdgeSpec{
|
||||||
Rel: sqlgraph.M2O,
|
Rel: sqlgraph.M2O,
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, acc
|
|||||||
pageSize := dataPageCap
|
pageSize := dataPageCap
|
||||||
var out []service.Account
|
var out []service.Account
|
||||||
for {
|
for {
|
||||||
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search)
|
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,12 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
search = search[:100]
|
search = search[:100]
|
||||||
}
|
}
|
||||||
|
|
||||||
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search)
|
var groupID int64
|
||||||
|
if groupIDStr := c.Query("group"); groupIDStr != "" {
|
||||||
|
groupID, _ = strconv.ParseInt(groupIDStr, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -1429,7 +1434,7 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
|
|||||||
accounts := make([]*service.Account, 0)
|
accounts := make([]*service.Account, 0)
|
||||||
|
|
||||||
if len(req.AccountIDs) == 0 {
|
if len(req.AccountIDs) == 0 {
|
||||||
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "")
|
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
|||||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]service.Account, int64, error) {
|
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
|
||||||
return s.accounts, int64(len(s.accounts)), nil
|
return s.accounts, int64(len(s.accounts)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,27 @@ func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, tokenInfo)
|
response.Success(c, tokenInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AntigravityRefreshTokenRequest represents the request for validating Antigravity refresh token
|
||||||
|
type AntigravityRefreshTokenRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken validates an Antigravity refresh token and returns full token info
|
||||||
|
// POST /api/v1/admin/antigravity/oauth/refresh-token
|
||||||
|
func (h *AntigravityOAuthHandler) RefreshToken(c *gin.Context) {
|
||||||
|
var req AntigravityRefreshTokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请求无效: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenInfo, err := h.antigravityOAuthService.ValidateRefreshToken(c.Request.Context(), req.RefreshToken, req.ProxyID)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, tokenInfo)
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
|
|||||||
writer := csv.NewWriter(&buf)
|
writer := csv.NewWriter(&buf)
|
||||||
|
|
||||||
// Write header
|
// Write header
|
||||||
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_at", "created_at"}); err != nil {
|
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil {
|
||||||
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
|
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
|
|||||||
if code.UsedBy != nil {
|
if code.UsedBy != nil {
|
||||||
usedBy = fmt.Sprintf("%d", *code.UsedBy)
|
usedBy = fmt.Sprintf("%d", *code.UsedBy)
|
||||||
}
|
}
|
||||||
|
usedByEmail := ""
|
||||||
|
if code.User != nil {
|
||||||
|
usedByEmail = code.User.Email
|
||||||
|
}
|
||||||
usedAt := ""
|
usedAt := ""
|
||||||
if code.UsedAt != nil {
|
if code.UsedAt != nil {
|
||||||
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
|
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
|
||||||
@@ -224,6 +228,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
|
|||||||
fmt.Sprintf("%.2f", code.Value),
|
fmt.Sprintf("%.2f", code.Value),
|
||||||
code.Status,
|
code.Status,
|
||||||
usedBy,
|
usedBy,
|
||||||
|
usedByEmail,
|
||||||
usedAt,
|
usedAt,
|
||||||
code.CreatedAt.Format("2006-01-02 15:04:05"),
|
code.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
|||||||
enabled := true
|
enabled := true
|
||||||
out.EnableSessionIDMasking = &enabled
|
out.EnableSessionIDMasking = &enabled
|
||||||
}
|
}
|
||||||
|
// 缓存 TTL 强制替换
|
||||||
|
if a.IsCacheTTLOverrideEnabled() {
|
||||||
|
enabled := true
|
||||||
|
out.CacheTTLOverrideEnabled = &enabled
|
||||||
|
target := a.GetCacheTTLOverrideTarget()
|
||||||
|
out.CacheTTLOverrideTarget = &target
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
@@ -398,6 +405,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
|||||||
ImageCount: l.ImageCount,
|
ImageCount: l.ImageCount,
|
||||||
ImageSize: l.ImageSize,
|
ImageSize: l.ImageSize,
|
||||||
UserAgent: l.UserAgent,
|
UserAgent: l.UserAgent,
|
||||||
|
CacheTTLOverridden: l.CacheTTLOverridden,
|
||||||
CreatedAt: l.CreatedAt,
|
CreatedAt: l.CreatedAt,
|
||||||
User: UserFromServiceShallow(l.User),
|
User: UserFromServiceShallow(l.User),
|
||||||
APIKey: APIKeyFromService(l.APIKey),
|
APIKey: APIKeyFromService(l.APIKey),
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ type Account struct {
|
|||||||
// 从 extra 字段提取,方便前端显示和编辑
|
// 从 extra 字段提取,方便前端显示和编辑
|
||||||
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
|
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
|
||||||
|
|
||||||
|
// 缓存 TTL 强制替换(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||||
|
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型计费
|
||||||
|
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
|
||||||
|
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
|
||||||
|
|
||||||
Proxy *Proxy `json:"proxy,omitempty"`
|
Proxy *Proxy `json:"proxy,omitempty"`
|
||||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||||
|
|
||||||
@@ -273,6 +278,9 @@ type UsageLog struct {
|
|||||||
// User-Agent
|
// User-Agent
|
||||||
UserAgent *string `json:"user_agent"`
|
UserAgent *string `json:"user_agent"`
|
||||||
|
|
||||||
|
// Cache TTL Override 标记
|
||||||
|
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
User *User `json:"user,omitempty"`
|
User *User `json:"user,omitempty"`
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type ClaudeMessage struct {
|
|||||||
|
|
||||||
// ThinkingConfig Thinking 配置
|
// ThinkingConfig Thinking 配置
|
||||||
type ThinkingConfig struct {
|
type ThinkingConfig struct {
|
||||||
Type string `json:"type"` // "enabled" or "disabled"
|
Type string `json:"type"` // "enabled" / "adaptive" / "disabled"
|
||||||
BudgetTokens int `json:"budget_tokens,omitempty"` // thinking budget
|
BudgetTokens int `json:"budget_tokens,omitempty"` // thinking budget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ type GeminiUsageMetadata struct {
|
|||||||
CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
|
CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
|
||||||
CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
|
CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
|
||||||
TotalTokenCount int `json:"totalTokenCount,omitempty"`
|
TotalTokenCount int `json:"totalTokenCount,omitempty"`
|
||||||
|
ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"` // thinking tokens(按输出价格计费)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeminiGroundingMetadata Gemini grounding 元数据(Web Search)
|
// GeminiGroundingMetadata Gemini grounding 元数据(Web Search)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const MaxTokensBudgetPadding = 1000
|
|||||||
// Gemini 2.5 Flash thinking budget 上限
|
// Gemini 2.5 Flash thinking budget 上限
|
||||||
const Gemini25FlashThinkingBudgetLimit = 24576
|
const Gemini25FlashThinkingBudgetLimit = 24576
|
||||||
|
|
||||||
|
// 对于 Antigravity 的 Claude(budget-only)模型,该语义最终等价为 thinkingBudget=24576。
|
||||||
|
// 这里复用相同数值以保持行为一致。
|
||||||
|
const ClaudeAdaptiveHighThinkingBudgetTokens = Gemini25FlashThinkingBudgetLimit
|
||||||
|
|
||||||
// ensureMaxTokensGreaterThanBudget 确保 max_tokens > budget_tokens
|
// ensureMaxTokensGreaterThanBudget 确保 max_tokens > budget_tokens
|
||||||
// Claude API 要求启用 thinking 时,max_tokens 必须大于 thinking.budget_tokens
|
// Claude API 要求启用 thinking 时,max_tokens 必须大于 thinking.budget_tokens
|
||||||
// 返回调整后的 maxTokens 和是否进行了调整
|
// 返回调整后的 maxTokens 和是否进行了调整
|
||||||
@@ -96,7 +100,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检测是否启用 thinking
|
// 检测是否启用 thinking
|
||||||
isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
isThinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
|
||||||
|
|
||||||
// 只有 Gemini 模型支持 dummy thought workaround
|
// 只有 Gemini 模型支持 dummy thought workaround
|
||||||
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
||||||
@@ -198,8 +202,7 @@ type modelInfo struct {
|
|||||||
|
|
||||||
// modelInfoMap 模型前缀 → 模型信息映射
|
// modelInfoMap 模型前缀 → 模型信息映射
|
||||||
// 只有在此映射表中的模型才会注入身份提示词
|
// 只有在此映射表中的模型才会注入身份提示词
|
||||||
// 注意:当前 claude-opus-4-6 会被映射到 claude-opus-4-5-thinking,
|
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
|
||||||
// 但保留此条目以便后续 Antigravity 上游支持 4.6 时快速切换
|
|
||||||
var modelInfoMap = map[string]modelInfo{
|
var modelInfoMap = map[string]modelInfo{
|
||||||
"claude-opus-4-5": {DisplayName: "Claude Opus 4.5", CanonicalID: "claude-opus-4-5-20250929"},
|
"claude-opus-4-5": {DisplayName: "Claude Opus 4.5", CanonicalID: "claude-opus-4-5-20250929"},
|
||||||
"claude-opus-4-6": {DisplayName: "Claude Opus 4.6", CanonicalID: "claude-opus-4-6"},
|
"claude-opus-4-6": {DisplayName: "Claude Opus 4.6", CanonicalID: "claude-opus-4-6"},
|
||||||
@@ -593,6 +596,10 @@ func maxOutputTokensLimit(model string) int {
|
|||||||
return maxOutputTokensUpperBound
|
return maxOutputTokensUpperBound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAntigravityOpus46Model(model string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(model), "claude-opus-4-6")
|
||||||
|
}
|
||||||
|
|
||||||
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
||||||
maxLimit := maxOutputTokensLimit(req.Model)
|
maxLimit := maxOutputTokensLimit(req.Model)
|
||||||
config := &GeminiGenerationConfig{
|
config := &GeminiGenerationConfig{
|
||||||
@@ -606,25 +613,36 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Thinking 配置
|
// Thinking 配置
|
||||||
if req.Thinking != nil && req.Thinking.Type == "enabled" {
|
if req.Thinking != nil && (req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive") {
|
||||||
config.ThinkingConfig = &GeminiThinkingConfig{
|
config.ThinkingConfig = &GeminiThinkingConfig{
|
||||||
IncludeThoughts: true,
|
IncludeThoughts: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// - thinking.type=enabled:budget_tokens>0 用显式预算
|
||||||
|
// - thinking.type=adaptive:仅在 Antigravity 的 Opus 4.6 上覆写为 (24576)
|
||||||
|
budget := -1
|
||||||
if req.Thinking.BudgetTokens > 0 {
|
if req.Thinking.BudgetTokens > 0 {
|
||||||
budget := req.Thinking.BudgetTokens
|
budget = req.Thinking.BudgetTokens
|
||||||
|
}
|
||||||
|
if req.Thinking.Type == "adaptive" && isAntigravityOpus46Model(req.Model) {
|
||||||
|
budget = ClaudeAdaptiveHighThinkingBudgetTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正预算需要做上限与 max_tokens 约束;动态预算(-1)直接透传给上游。
|
||||||
|
if budget > 0 {
|
||||||
// gemini-2.5-flash 上限
|
// gemini-2.5-flash 上限
|
||||||
if strings.Contains(req.Model, "gemini-2.5-flash") && budget > Gemini25FlashThinkingBudgetLimit {
|
if strings.Contains(req.Model, "gemini-2.5-flash") && budget > Gemini25FlashThinkingBudgetLimit {
|
||||||
budget = Gemini25FlashThinkingBudgetLimit
|
budget = Gemini25FlashThinkingBudgetLimit
|
||||||
}
|
}
|
||||||
config.ThinkingConfig.ThinkingBudget = budget
|
|
||||||
|
|
||||||
// 自动修正:max_tokens 必须大于 budget_tokens
|
// 自动修正:max_tokens 必须大于 budget_tokens(Claude 上游要求)
|
||||||
if adjusted, ok := ensureMaxTokensGreaterThanBudget(config.MaxOutputTokens, budget); ok {
|
if adjusted, ok := ensureMaxTokensGreaterThanBudget(config.MaxOutputTokens, budget); ok {
|
||||||
log.Printf("[Antigravity] Auto-adjusted max_tokens from %d to %d (must be > budget_tokens=%d)",
|
log.Printf("[Antigravity] Auto-adjusted max_tokens from %d to %d (must be > budget_tokens=%d)",
|
||||||
config.MaxOutputTokens, adjusted, budget)
|
config.MaxOutputTokens, adjusted, budget)
|
||||||
config.MaxOutputTokens = adjusted
|
config.MaxOutputTokens = adjusted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
config.ThinkingConfig.ThinkingBudget = budget
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.MaxOutputTokens > maxLimit {
|
if config.MaxOutputTokens > maxLimit {
|
||||||
|
|||||||
@@ -259,3 +259,93 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
thinking *ThinkingConfig
|
||||||
|
wantBudget int
|
||||||
|
wantPresent bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "enabled without budget defaults to dynamic (-1)",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "enabled"},
|
||||||
|
wantBudget: -1,
|
||||||
|
wantPresent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled with budget uses the provided value",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1024},
|
||||||
|
wantBudget: 1024,
|
||||||
|
wantPresent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled with -1 budget uses dynamic (-1)",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: -1},
|
||||||
|
wantBudget: -1,
|
||||||
|
wantPresent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adaptive on opus4.6 maps to high budget (24576)",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "adaptive", BudgetTokens: 20000},
|
||||||
|
wantBudget: ClaudeAdaptiveHighThinkingBudgetTokens,
|
||||||
|
wantPresent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adaptive on non-opus model keeps default dynamic (-1)",
|
||||||
|
model: "claude-sonnet-4-5-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "adaptive"},
|
||||||
|
wantBudget: -1,
|
||||||
|
wantPresent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled does not emit thinkingConfig",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: &ThinkingConfig{Type: "disabled", BudgetTokens: 1024},
|
||||||
|
wantBudget: 0,
|
||||||
|
wantPresent: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil thinking does not emit thinkingConfig",
|
||||||
|
model: "claude-opus-4-6-thinking",
|
||||||
|
thinking: nil,
|
||||||
|
wantBudget: 0,
|
||||||
|
wantPresent: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := &ClaudeRequest{
|
||||||
|
Model: tt.model,
|
||||||
|
Thinking: tt.thinking,
|
||||||
|
}
|
||||||
|
cfg := buildGenerationConfig(req)
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatalf("expected non-nil generationConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantPresent {
|
||||||
|
if cfg.ThinkingConfig == nil {
|
||||||
|
t.Fatalf("expected thinkingConfig to be present")
|
||||||
|
}
|
||||||
|
if !cfg.ThinkingConfig.IncludeThoughts {
|
||||||
|
t.Fatalf("expected includeThoughts=true")
|
||||||
|
}
|
||||||
|
if cfg.ThinkingConfig.ThinkingBudget != tt.wantBudget {
|
||||||
|
t.Fatalf("expected thinkingBudget=%d, got %d", tt.wantBudget, cfg.ThinkingConfig.ThinkingBudget)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ThinkingConfig != nil {
|
||||||
|
t.Fatalf("expected thinkingConfig to be nil, got %+v", cfg.ThinkingConfig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
|
|||||||
if geminiResp.UsageMetadata != nil {
|
if geminiResp.UsageMetadata != nil {
|
||||||
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
||||||
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
||||||
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
|
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
|
||||||
usage.CacheReadInputTokens = cached
|
usage.CacheReadInputTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
|
|||||||
if geminiResp.UsageMetadata != nil {
|
if geminiResp.UsageMetadata != nil {
|
||||||
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
||||||
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
||||||
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
|
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
|
||||||
p.cacheReadTokens = cached
|
p.cacheReadTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
|
|||||||
if v1Resp.Response.UsageMetadata != nil {
|
if v1Resp.Response.UsageMetadata != nil {
|
||||||
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
|
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
|
||||||
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
|
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
|
||||||
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount
|
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount + v1Resp.Response.UsageMetadata.ThoughtsTokenCount
|
||||||
usage.CacheReadInputTokens = cached
|
usage.CacheReadInputTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const (
|
|||||||
BetaInterleavedThinking = "interleaved-thinking-2025-05-14"
|
BetaInterleavedThinking = "interleaved-thinking-2025-05-14"
|
||||||
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
||||||
BetaTokenCounting = "token-counting-2024-11-01"
|
BetaTokenCounting = "token-counting-2024-11-01"
|
||||||
|
BetaContext1M = "context-1m-2025-08-07"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
|
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
|
||||||
@@ -77,6 +78,12 @@ var DefaultModels = []Model{
|
|||||||
DisplayName: "Claude Opus 4.6",
|
DisplayName: "Claude Opus 4.6",
|
||||||
CreatedAt: "2026-02-06T00:00:00Z",
|
CreatedAt: "2026-02-06T00:00:00Z",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "claude-sonnet-4-6",
|
||||||
|
Type: "model",
|
||||||
|
DisplayName: "Claude Sonnet 4.6",
|
||||||
|
CreatedAt: "2026-02-18T00:00:00Z",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-sonnet-4-5-20250929",
|
ID: "claude-sonnet-4-5-20250929",
|
||||||
Type: "model",
|
Type: "model",
|
||||||
|
|||||||
@@ -435,10 +435,10 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *accountRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.Account, *pagination.PaginationResult, error) {
|
func (r *accountRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.Account, *pagination.PaginationResult, error) {
|
||||||
return r.ListWithFilters(ctx, params, "", "", "", "")
|
return r.ListWithFilters(ctx, params, "", "", "", "", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]service.Account, *pagination.PaginationResult, error) {
|
func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]service.Account, *pagination.PaginationResult, error) {
|
||||||
q := r.client.Account.Query()
|
q := r.client.Account.Query()
|
||||||
|
|
||||||
if platform != "" {
|
if platform != "" {
|
||||||
@@ -448,11 +448,19 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
|
|||||||
q = q.Where(dbaccount.TypeEQ(accountType))
|
q = q.Where(dbaccount.TypeEQ(accountType))
|
||||||
}
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
q = q.Where(dbaccount.StatusEQ(status))
|
switch status {
|
||||||
|
case "rate_limited":
|
||||||
|
q = q.Where(dbaccount.RateLimitResetAtGT(time.Now()))
|
||||||
|
default:
|
||||||
|
q = q.Where(dbaccount.StatusEQ(status))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
q = q.Where(dbaccount.NameContainsFold(search))
|
q = q.Where(dbaccount.NameContainsFold(search))
|
||||||
}
|
}
|
||||||
|
if groupID > 0 {
|
||||||
|
q = q.Where(dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDEQ(groupID)))
|
||||||
|
}
|
||||||
|
|
||||||
total, err := q.Count(ctx)
|
total, err := q.Count(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
|
|||||||
|
|
||||||
tt.setup(client)
|
tt.setup(client)
|
||||||
|
|
||||||
accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search)
|
accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, 0)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.Require().Len(accounts, tt.wantCount)
|
s.Require().Len(accounts, tt.wantCount)
|
||||||
if tt.validate != nil {
|
if tt.validate != nil {
|
||||||
@@ -305,7 +305,7 @@ func (s *AccountRepoSuite) TestPreload_And_VirtualFields() {
|
|||||||
s.Require().Len(got.Groups, 1, "expected Groups to be populated")
|
s.Require().Len(got.Groups, 1, "expected Groups to be populated")
|
||||||
s.Require().Equal(group.ID, got.Groups[0].ID)
|
s.Require().Equal(group.ID, got.Groups[0].ID)
|
||||||
|
|
||||||
accounts, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", "acc")
|
accounts, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", "acc", 0)
|
||||||
s.Require().NoError(err, "ListWithFilters")
|
s.Require().NoError(err, "ListWithFilters")
|
||||||
s.Require().Equal(int64(1), page.Total)
|
s.Require().Equal(int64(1), page.Total)
|
||||||
s.Require().Len(accounts, 1)
|
s.Require().Len(accounts, 1)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
@@ -106,7 +107,12 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
|
|||||||
q = q.Where(redeemcode.StatusEQ(status))
|
q = q.Where(redeemcode.StatusEQ(status))
|
||||||
}
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
q = q.Where(redeemcode.CodeContainsFold(search))
|
q = q.Where(
|
||||||
|
redeemcode.Or(
|
||||||
|
redeemcode.CodeContainsFold(search),
|
||||||
|
redeemcode.HasUserWith(user.EmailContainsFold(search)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
total, err := q.Count(ctx)
|
total, err := q.Count(ctx)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, reasoning_effort, created_at"
|
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, reasoning_effort, cache_ttl_overridden, created_at"
|
||||||
|
|
||||||
type usageLogRepository struct {
|
type usageLogRepository struct {
|
||||||
client *dbent.Client
|
client *dbent.Client
|
||||||
@@ -115,6 +115,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
|||||||
image_count,
|
image_count,
|
||||||
image_size,
|
image_size,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
cache_ttl_overridden,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
@@ -122,7 +123,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
|||||||
$8, $9, $10, $11,
|
$8, $9, $10, $11,
|
||||||
$12, $13,
|
$12, $13,
|
||||||
$14, $15, $16, $17, $18, $19,
|
$14, $15, $16, $17, $18, $19,
|
||||||
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31
|
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32
|
||||||
)
|
)
|
||||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
@@ -173,6 +174,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
|
|||||||
log.ImageCount,
|
log.ImageCount,
|
||||||
imageSize,
|
imageSize,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
|
log.CacheTTLOverridden,
|
||||||
createdAt,
|
createdAt,
|
||||||
}
|
}
|
||||||
if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil {
|
if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil {
|
||||||
@@ -2195,6 +2197,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
|||||||
imageCount int
|
imageCount int
|
||||||
imageSize sql.NullString
|
imageSize sql.NullString
|
||||||
reasoningEffort sql.NullString
|
reasoningEffort sql.NullString
|
||||||
|
cacheTTLOverridden bool
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2230,6 +2233,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
|||||||
&imageCount,
|
&imageCount,
|
||||||
&imageSize,
|
&imageSize,
|
||||||
&reasoningEffort,
|
&reasoningEffort,
|
||||||
|
&cacheTTLOverridden,
|
||||||
&createdAt,
|
&createdAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2258,6 +2262,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
|||||||
BillingType: int8(billingType),
|
BillingType: int8(billingType),
|
||||||
Stream: stream,
|
Stream: stream,
|
||||||
ImageCount: imageCount,
|
ImageCount: imageCount,
|
||||||
|
CacheTTLOverridden: cacheTTLOverridden,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
|
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||||
@@ -191,6 +192,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
|
|||||||
dbuser.EmailContainsFold(filters.Search),
|
dbuser.EmailContainsFold(filters.Search),
|
||||||
dbuser.UsernameContainsFold(filters.Search),
|
dbuser.UsernameContainsFold(filters.Search),
|
||||||
dbuser.NotesContainsFold(filters.Search),
|
dbuser.NotesContainsFold(filters.Search),
|
||||||
|
dbuser.HasAPIKeysWith(apikey.KeyContainsFold(filters.Search)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"first_token_ms": 50,
|
"first_token_ms": 50,
|
||||||
"image_count": 0,
|
"image_count": 0,
|
||||||
"image_size": null,
|
"image_size": null,
|
||||||
|
"cache_ttl_overridden": false,
|
||||||
"created_at": "2025-01-02T03:04:05Z",
|
"created_at": "2025-01-02T03:04:05Z",
|
||||||
"user_agent": null
|
"user_agent": null
|
||||||
}
|
}
|
||||||
@@ -936,7 +937,7 @@ func (s *stubAccountRepo) List(ctx context.Context, params pagination.Pagination
|
|||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAccountRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]service.Account, *pagination.PaginationResult, error) {
|
func (s *stubAccountRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]service.Account, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,15 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key")
|
allowHeaders := []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key"}
|
||||||
|
|
||||||
|
// openai node sdk
|
||||||
|
openAIProperties := []string{"lang", "package-version", "os", "arch", "retry-count", "runtime", "runtime-version", "async", "helper-method", "poll-helper", "custom-poll-interval", "timeout"}
|
||||||
|
for _, prop := range openAIProperties {
|
||||||
|
allowHeaders = append(allowHeaders, "x-stainless-"+prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", strings.Join(allowHeaders, ", "))
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||||
|
|
||||||
// 处理预检请求
|
// 处理预检请求
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
|||||||
{
|
{
|
||||||
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
|
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
|
||||||
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
|
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
|
||||||
|
antigravity.POST("/oauth/refresh-token", h.Admin.AntigravityOAuth.RefreshToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -752,6 +752,38 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
|
||||||
|
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
|
||||||
|
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
|
||||||
|
func (a *Account) IsCacheTTLOverrideEnabled() bool {
|
||||||
|
if !a.IsAnthropicOAuthOrSetupToken() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Extra == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v, ok := a.Extra["cache_ttl_override_enabled"]; ok {
|
||||||
|
if enabled, ok := v.(bool); ok {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
|
||||||
|
// 返回 "5m" 或 "1h",默认 "5m"
|
||||||
|
func (a *Account) GetCacheTTLOverrideTarget() string {
|
||||||
|
if a.Extra == nil {
|
||||||
|
return "5m"
|
||||||
|
}
|
||||||
|
if v, ok := a.Extra["cache_ttl_override_target"]; ok {
|
||||||
|
if target, ok := v.(string); ok && (target == "5m" || target == "1h") {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "5m"
|
||||||
|
}
|
||||||
|
|
||||||
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
|
||||||
// 返回 0 表示未启用
|
// 返回 0 表示未启用
|
||||||
func (a *Account) GetWindowCostLimit() float64 {
|
func (a *Account) GetWindowCostLimit() float64 {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type AccountRepository interface {
|
|||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
|
List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
|
||||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error)
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error)
|
||||||
ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
|
ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
|
||||||
ListActive(ctx context.Context) ([]Account, error)
|
ListActive(ctx context.Context) ([]Account, error)
|
||||||
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func (s *accountRepoStub) List(ctx context.Context, params pagination.Pagination
|
|||||||
panic("unexpected List call")
|
panic("unexpected List call")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
func (s *accountRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||||
panic("unexpected ListWithFilters call")
|
panic("unexpected ListWithFilters call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type AdminService interface {
|
|||||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||||
|
|
||||||
// Account management
|
// Account management
|
||||||
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error)
|
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error)
|
||||||
GetAccount(ctx context.Context, id int64) (*Account, error)
|
GetAccount(ctx context.Context, id int64) (*Account, error)
|
||||||
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
||||||
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
||||||
@@ -1021,9 +1021,9 @@ func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Account management implementations
|
// Account management implementations
|
||||||
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) {
|
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) {
|
||||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search)
|
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type accountRepoStubForAdminList struct {
|
|||||||
listWithFiltersErr error
|
listWithFiltersErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||||
s.listWithFiltersCalls++
|
s.listWithFiltersCalls++
|
||||||
s.listWithFiltersParams = params
|
s.listWithFiltersParams = params
|
||||||
s.listWithFiltersPlatform = platform
|
s.listWithFiltersPlatform = platform
|
||||||
@@ -168,7 +168,7 @@ func TestAdminService_ListAccounts_WithSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
svc := &adminServiceImpl{accountRepo: repo}
|
svc := &adminServiceImpl{accountRepo: repo}
|
||||||
|
|
||||||
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc")
|
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc", 0)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(10), total)
|
require.Equal(t, int64(10), total)
|
||||||
require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts)
|
require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts)
|
||||||
|
|||||||
@@ -1309,7 +1309,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
|
return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
|
||||||
}
|
}
|
||||||
// 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本
|
// 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本
|
||||||
thinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
|
||||||
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
|
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
|
||||||
|
|
||||||
// 获取 access_token
|
// 获取 access_token
|
||||||
@@ -4111,6 +4111,15 @@ func (s *AntigravityGatewayService) extractSSEUsage(line string, usage *ClaudeUs
|
|||||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 {
|
if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 {
|
||||||
usage.CacheCreationInputTokens = int(v)
|
usage.CacheCreationInputTokens = int(v)
|
||||||
}
|
}
|
||||||
|
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||||
|
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||||
|
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||||
|
usage.CacheCreation5mTokens = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||||
|
usage.CacheCreation1hTokens = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractClaudeUsage 从非流式 Claude 响应提取 usage
|
// extractClaudeUsage 从非流式 Claude 响应提取 usage
|
||||||
@@ -4133,6 +4142,15 @@ func (s *AntigravityGatewayService) extractClaudeUsage(body []byte) *ClaudeUsage
|
|||||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok {
|
if v, ok := u["cache_creation_input_tokens"].(float64); ok {
|
||||||
usage.CacheCreationInputTokens = int(v)
|
usage.CacheCreationInputTokens = int(v)
|
||||||
}
|
}
|
||||||
|
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||||
|
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||||
|
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||||
|
usage.CacheCreation5mTokens = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||||
|
usage.CacheCreation1hTokens = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return usage
|
return usage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -553,6 +553,75 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
|
|||||||
require.NotContains(t, body, "event: error")
|
require.NotContains(t, body, "event: error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
|
||||||
|
// 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
|
||||||
|
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
svc := newAntigravityTestService(&config.Config{
|
||||||
|
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = pw.Close() }()
|
||||||
|
fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"thoughtsTokenCount":50}}`)
|
||||||
|
fmt.Fprintln(pw, "")
|
||||||
|
fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"thoughtsTokenCount":80,"cachedContentTokenCount":10}}`)
|
||||||
|
fmt.Fprintln(pw, "")
|
||||||
|
}()
|
||||||
|
|
||||||
|
result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
|
||||||
|
_ = pr.Close()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.NotNil(t, result.usage)
|
||||||
|
// promptTokenCount=100, cachedContentTokenCount=10 → InputTokens=90
|
||||||
|
require.Equal(t, 90, result.usage.InputTokens)
|
||||||
|
// candidatesTokenCount=30 + thoughtsTokenCount=80 → OutputTokens=110
|
||||||
|
require.Equal(t, 110, result.usage.OutputTokens)
|
||||||
|
require.Equal(t, 10, result.usage.CacheReadInputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleClaudeStreamingResponse_ThoughtsTokenCount
|
||||||
|
// 验证:Gemini→Claude 流式转换时 thoughtsTokenCount 被计入 OutputTokens
|
||||||
|
func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
svc := newAntigravityTestService(&config.Config{
|
||||||
|
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = pw.Close() }()
|
||||||
|
fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":10,"thoughtsTokenCount":25}}}`)
|
||||||
|
fmt.Fprintln(pw, "")
|
||||||
|
}()
|
||||||
|
|
||||||
|
result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "gemini-2.5-pro")
|
||||||
|
_ = pr.Close()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.NotNil(t, result.usage)
|
||||||
|
// promptTokenCount=50 → InputTokens=50
|
||||||
|
require.Equal(t, 50, result.usage.InputTokens)
|
||||||
|
// candidatesTokenCount=10 + thoughtsTokenCount=25 → OutputTokens=35
|
||||||
|
require.Equal(t, 35, result.usage.OutputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 流式客户端断开检测测试 ---
|
// --- 流式客户端断开检测测试 ---
|
||||||
|
|
||||||
// TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
|
// TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
|
||||||
|
|||||||
@@ -192,6 +192,43 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
|
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id)
|
||||||
|
func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*AntigravityTokenInfo, error) {
|
||||||
|
var proxyURL string
|
||||||
|
if proxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||||||
|
if err == nil && proxy != nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新 token
|
||||||
|
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息(email)
|
||||||
|
client := antigravity.NewClient(proxyURL)
|
||||||
|
userInfo, err := client.GetUserInfo(ctx, tokenInfo.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
tokenInfo.Email = userInfo.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 project_id(容错,失败不阻塞)
|
||||||
|
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||||
|
if loadErr != nil {
|
||||||
|
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
|
||||||
|
tokenInfo.ProjectIDMissing = true
|
||||||
|
} else {
|
||||||
|
tokenInfo.ProjectID = projectID
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
func isNonRetryableAntigravityOAuthError(err error) bool {
|
func isNonRetryableAntigravityOAuthError(err error) bool {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
nonRetryable := []string{
|
nonRetryable := []string{
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ type ModelPricing struct {
|
|||||||
OutputPricePerToken float64 // 每token输出价格 (USD)
|
OutputPricePerToken float64 // 每token输出价格 (USD)
|
||||||
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD)
|
||||||
CacheReadPricePerToken float64 // 缓存读取每token价格 (USD)
|
CacheReadPricePerToken float64 // 缓存读取每token价格 (USD)
|
||||||
CacheCreation5mPrice float64 // 5分钟缓存创建价格(每百万token)- 仅用于硬编码回退
|
CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD)
|
||||||
CacheCreation1hPrice float64 // 1小时缓存创建价格(每百万token)- 仅用于硬编码回退
|
CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD)
|
||||||
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
|
SupportsCacheBreakdown bool // 是否支持详细的缓存分类
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +172,20 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
|||||||
if s.pricingService != nil {
|
if s.pricingService != nil {
|
||||||
litellmPricing := s.pricingService.GetModelPricing(model)
|
litellmPricing := s.pricingService.GetModelPricing(model)
|
||||||
if litellmPricing != nil {
|
if litellmPricing != nil {
|
||||||
|
// 启用 5m/1h 分类计费的条件:
|
||||||
|
// 1. 存在 1h 价格
|
||||||
|
// 2. 1h 价格 > 5m 价格(防止 LiteLLM 数据错误导致少收费)
|
||||||
|
price5m := litellmPricing.CacheCreationInputTokenCost
|
||||||
|
price1h := litellmPricing.CacheCreationInputTokenCostAbove1hr
|
||||||
|
enableBreakdown := price1h > 0 && price1h > price5m
|
||||||
return &ModelPricing{
|
return &ModelPricing{
|
||||||
InputPricePerToken: litellmPricing.InputCostPerToken,
|
InputPricePerToken: litellmPricing.InputCostPerToken,
|
||||||
OutputPricePerToken: litellmPricing.OutputCostPerToken,
|
OutputPricePerToken: litellmPricing.OutputCostPerToken,
|
||||||
CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost,
|
CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost,
|
||||||
CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost,
|
CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost,
|
||||||
SupportsCacheBreakdown: false,
|
CacheCreation5mPrice: price5m,
|
||||||
|
CacheCreation1hPrice: price1h,
|
||||||
|
SupportsCacheBreakdown: enableBreakdown,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,9 +217,14 @@ func (s *BillingService) CalculateCost(model string, tokens UsageTokens, rateMul
|
|||||||
|
|
||||||
// 计算缓存费用
|
// 计算缓存费用
|
||||||
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) {
|
||||||
// 支持详细缓存分类的模型(5分钟/1小时缓存)
|
// 支持详细缓存分类的模型(5分钟/1小时缓存,价格为 per-token)
|
||||||
breakdown.CacheCreationCost = float64(tokens.CacheCreation5mTokens)/1_000_000*pricing.CacheCreation5mPrice +
|
if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 {
|
||||||
float64(tokens.CacheCreation1hTokens)/1_000_000*pricing.CacheCreation1hPrice
|
// API 未返回 ephemeral 明细,回退到全部按 5m 单价计费
|
||||||
|
breakdown.CacheCreationCost = float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice
|
||||||
|
} else {
|
||||||
|
breakdown.CacheCreationCost = float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice +
|
||||||
|
float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 标准缓存创建价格(per-token)
|
// 标准缓存创建价格(per-token)
|
||||||
breakdown.CacheCreationCost = float64(tokens.CacheCreationTokens) * pricing.CacheCreationPricePerToken
|
breakdown.CacheCreationCost = float64(tokens.CacheCreationTokens) * pricing.CacheCreationPricePerToken
|
||||||
@@ -280,10 +293,12 @@ func (s *BillingService) CalculateCostWithLongContext(model string, tokens Usage
|
|||||||
|
|
||||||
// 范围内部分:正常计费
|
// 范围内部分:正常计费
|
||||||
inRangeTokens := UsageTokens{
|
inRangeTokens := UsageTokens{
|
||||||
InputTokens: inRangeInputTokens,
|
InputTokens: inRangeInputTokens,
|
||||||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||||||
CacheCreationTokens: tokens.CacheCreationTokens,
|
CacheCreationTokens: tokens.CacheCreationTokens,
|
||||||
CacheReadTokens: inRangeCacheTokens,
|
CacheReadTokens: inRangeCacheTokens,
|
||||||
|
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
|
||||||
|
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
|
||||||
}
|
}
|
||||||
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,3 +21,72 @@ func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) {
|
|||||||
)
|
)
|
||||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
|
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStripBetaToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
token string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "token in middle",
|
||||||
|
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token at start",
|
||||||
|
header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token at end",
|
||||||
|
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token not present",
|
||||||
|
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty header",
|
||||||
|
header: "",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with spaces",
|
||||||
|
header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only token",
|
||||||
|
header: "context-1m-2025-08-07",
|
||||||
|
token: "context-1m-2025-08-07",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := stripBetaToken(tt.header, tt.token)
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
|
||||||
|
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
|
||||||
|
incoming := "context-1m-2025-08-07,foo-beta,oauth-2025-04-20"
|
||||||
|
drop := map[string]struct{}{"context-1m-2025-08-07": {}}
|
||||||
|
|
||||||
|
got := mergeAnthropicBetaDropping(required, incoming, drop)
|
||||||
|
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
||||||
|
require.NotContains(t, got, "context-1m-2025-08-07")
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func (m *mockAccountRepoForPlatform) Delete(ctx context.Context, id int64) error
|
|||||||
func (m *mockAccountRepoForPlatform) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
func (m *mockAccountRepoForPlatform) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForPlatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
func (m *mockAccountRepoForPlatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ func ParseGatewayRequest(body []byte, protocol string) (*ParsedRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// thinking: {type: "enabled"}
|
// thinking: {type: "enabled" | "adaptive"}
|
||||||
if rawThinking, ok := req["thinking"].(map[string]any); ok {
|
if rawThinking, ok := req["thinking"].(map[string]any); ok {
|
||||||
if t, ok := rawThinking["type"].(string); ok && t == "enabled" {
|
if t, ok := rawThinking["type"].(string); ok && (t == "enabled" || t == "adaptive") {
|
||||||
parsed.ThinkingEnabled = true
|
parsed.ThinkingEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,9 +161,9 @@ func parseIntegralNumber(raw any) (int, bool) {
|
|||||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||||
// This prevents 400 errors from invalid thinking block signatures
|
// This prevents 400 errors from invalid thinking block signatures
|
||||||
//
|
//
|
||||||
// Strategy:
|
// 策略:
|
||||||
// - When thinking.type != "enabled": Remove all thinking blocks
|
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
|
||||||
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
|
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400)
|
||||||
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
|
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
|
||||||
func FilterThinkingBlocks(body []byte) []byte {
|
func FilterThinkingBlocks(body []byte) []byte {
|
||||||
return filterThinkingBlocksInternal(body, false)
|
return filterThinkingBlocksInternal(body, false)
|
||||||
@@ -489,9 +489,9 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filterThinkingBlocksInternal removes invalid thinking blocks from request
|
// filterThinkingBlocksInternal removes invalid thinking blocks from request
|
||||||
// Strategy:
|
// 策略:
|
||||||
// - When thinking.type != "enabled": Remove all thinking blocks
|
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
|
||||||
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
|
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块
|
||||||
func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
||||||
// Fast path: if body doesn't contain "thinking", skip parsing
|
// Fast path: if body doesn't contain "thinking", skip parsing
|
||||||
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
|
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
|
||||||
@@ -511,7 +511,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
|||||||
// Check if thinking is enabled
|
// Check if thinking is enabled
|
||||||
thinkingEnabled := false
|
thinkingEnabled := false
|
||||||
if thinking, ok := req["thinking"].(map[string]any); ok {
|
if thinking, ok := req["thinking"].(map[string]any); ok {
|
||||||
if thinkType, ok := thinking["type"].(string); ok && thinkType == "enabled" {
|
if thinkType, ok := thinking["type"].(string); ok && (thinkType == "enabled" || thinkType == "adaptive") {
|
||||||
thinkingEnabled = true
|
thinkingEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
|
|||||||
require.True(t, parsed.ThinkingEnabled)
|
require.True(t, parsed.ThinkingEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) {
|
||||||
|
body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"adaptive"},"messages":[{"content":"hi"}]}`)
|
||||||
|
parsed, err := ParseGatewayRequest(body, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "claude-sonnet-4-5", parsed.Model)
|
||||||
|
require.True(t, parsed.ThinkingEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
|
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
|
||||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
||||||
parsed, err := ParseGatewayRequest(body, "")
|
parsed, err := ParseGatewayRequest(body, "")
|
||||||
@@ -209,6 +217,16 @@ func TestFilterThinkingBlocks(t *testing.T) {
|
|||||||
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`,
|
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`,
|
||||||
shouldFilter: true,
|
shouldFilter: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "does not filter signed thinking blocks when thinking adaptive",
|
||||||
|
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"ok","signature":"sig_real_123"},{"type":"text","text":"B"}]}]}`,
|
||||||
|
shouldFilter: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filters unsigned thinking blocks when thinking adaptive",
|
||||||
|
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"internal","signature":""},{"type":"text","text":"B"}]}]}`,
|
||||||
|
shouldFilter: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "handles no thinking blocks",
|
name: "handles no thinking blocks",
|
||||||
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
|
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ type ClaudeUsage struct {
|
|||||||
OutputTokens int `json:"output_tokens"`
|
OutputTokens int `json:"output_tokens"`
|
||||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||||
|
CacheCreation5mTokens int // 5分钟缓存创建token(来自嵌套 cache_creation 对象)
|
||||||
|
CacheCreation1hTokens int // 1小时缓存创建token(来自嵌套 cache_creation 对象)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForwardResult 转发结果
|
// ForwardResult 转发结果
|
||||||
@@ -3551,12 +3553,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
// messages requests typically use only oauth + interleaved-thinking.
|
// messages requests typically use only oauth + interleaved-thinking.
|
||||||
// Also drop claude-code beta if a downstream client added it.
|
// Also drop claude-code beta if a downstream client added it.
|
||||||
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
||||||
drop := map[string]struct{}{claude.BetaClaudeCode: {}}
|
drop := map[string]struct{}{claude.BetaClaudeCode: {}, claude.BetaContext1M: {}}
|
||||||
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
||||||
} else {
|
} else {
|
||||||
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
||||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||||
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, clientBetaHeader))
|
req.Header.Set("anthropic-beta", stripBetaToken(s.getBetaHeader(modelID, clientBetaHeader), claude.BetaContext1M))
|
||||||
}
|
}
|
||||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
||||||
@@ -3631,7 +3633,8 @@ func requestNeedsBetaFeatures(body []byte) bool {
|
|||||||
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.EqualFold(gjson.GetBytes(body, "thinking.type").String(), "enabled") {
|
thinkingType := gjson.GetBytes(body, "thinking.type").String()
|
||||||
|
if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -3709,6 +3712,23 @@ func mergeAnthropicBetaDropping(required []string, incoming string, drop map[str
|
|||||||
return strings.Join(out, ",")
|
return strings.Join(out, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripBetaToken removes a single beta token from a comma-separated header value.
|
||||||
|
// It short-circuits when the token is not present to avoid unnecessary allocations.
|
||||||
|
func stripBetaToken(header, token string) string {
|
||||||
|
if !strings.Contains(header, token) {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
out := make([]string, 0, 8)
|
||||||
|
for _, p := range strings.Split(header, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" || p == token {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
||||||
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
||||||
// headers when using Claude Code-scoped OAuth credentials.
|
// headers when using Claude Code-scoped OAuth credentials.
|
||||||
@@ -4273,6 +4293,23 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
|
||||||
|
if account.IsCacheTTLOverrideEnabled() {
|
||||||
|
overrideTarget := account.GetCacheTTLOverrideTarget()
|
||||||
|
if eventType == "message_start" {
|
||||||
|
if msg, ok := event["message"].(map[string]any); ok {
|
||||||
|
if u, ok := msg["usage"].(map[string]any); ok {
|
||||||
|
rewriteCacheCreationJSON(u, overrideTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if eventType == "message_delta" {
|
||||||
|
if u, ok := event["usage"].(map[string]any); ok {
|
||||||
|
rewriteCacheCreationJSON(u, overrideTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if needModelReplace {
|
if needModelReplace {
|
||||||
if msg, ok := event["message"].(map[string]any); ok {
|
if msg, ok := event["message"].(map[string]any); ok {
|
||||||
if model, ok := msg["model"].(string); ok && model == mappedModel {
|
if model, ok := msg["model"].(string); ok && model == mappedModel {
|
||||||
@@ -4400,6 +4437,14 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
|||||||
usage.InputTokens = msgStart.Message.Usage.InputTokens
|
usage.InputTokens = msgStart.Message.Usage.InputTokens
|
||||||
usage.CacheCreationInputTokens = msgStart.Message.Usage.CacheCreationInputTokens
|
usage.CacheCreationInputTokens = msgStart.Message.Usage.CacheCreationInputTokens
|
||||||
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
||||||
|
|
||||||
|
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||||
|
cc5m := gjson.Get(data, "message.usage.cache_creation.ephemeral_5m_input_tokens")
|
||||||
|
cc1h := gjson.Get(data, "message.usage.cache_creation.ephemeral_1h_input_tokens")
|
||||||
|
if cc5m.Exists() || cc1h.Exists() {
|
||||||
|
usage.CacheCreation5mTokens = int(cc5m.Int())
|
||||||
|
usage.CacheCreation1hTokens = int(cc1h.Int())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
||||||
@@ -4428,6 +4473,66 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
|||||||
if msgDelta.Usage.CacheReadInputTokens > 0 {
|
if msgDelta.Usage.CacheReadInputTokens > 0 {
|
||||||
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||||
|
cc5m := gjson.Get(data, "usage.cache_creation.ephemeral_5m_input_tokens")
|
||||||
|
cc1h := gjson.Get(data, "usage.cache_creation.ephemeral_1h_input_tokens")
|
||||||
|
if cc5m.Exists() || cc1h.Exists() {
|
||||||
|
usage.CacheCreation5mTokens = int(cc5m.Int())
|
||||||
|
usage.CacheCreation1hTokens = int(cc1h.Int())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCacheTTLOverride 将所有 cache creation tokens 归入指定的 TTL 类型。
|
||||||
|
// target 为 "5m" 或 "1h"。返回 true 表示发生了变更。
|
||||||
|
func applyCacheTTLOverride(usage *ClaudeUsage, target string) bool {
|
||||||
|
// Fallback: 如果只有聚合字段但无 5m/1h 明细,将聚合字段归入 5m 默认类别
|
||||||
|
if usage.CacheCreation5mTokens == 0 && usage.CacheCreation1hTokens == 0 && usage.CacheCreationInputTokens > 0 {
|
||||||
|
usage.CacheCreation5mTokens = usage.CacheCreationInputTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
total := usage.CacheCreation5mTokens + usage.CacheCreation1hTokens
|
||||||
|
if total == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch target {
|
||||||
|
case "1h":
|
||||||
|
if usage.CacheCreation1hTokens == total {
|
||||||
|
return false // 已经全是 1h
|
||||||
|
}
|
||||||
|
usage.CacheCreation1hTokens = total
|
||||||
|
usage.CacheCreation5mTokens = 0
|
||||||
|
default: // "5m"
|
||||||
|
if usage.CacheCreation5mTokens == total {
|
||||||
|
return false // 已经全是 5m
|
||||||
|
}
|
||||||
|
usage.CacheCreation5mTokens = total
|
||||||
|
usage.CacheCreation1hTokens = 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteCacheCreationJSON 在 JSON usage 对象中重写 cache_creation 嵌套对象的 TTL 分类。
|
||||||
|
// usageObj 是 usage JSON 对象(map[string]any)。
|
||||||
|
func rewriteCacheCreationJSON(usageObj map[string]any, target string) {
|
||||||
|
ccObj, ok := usageObj["cache_creation"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v5m, _ := ccObj["ephemeral_5m_input_tokens"].(float64)
|
||||||
|
v1h, _ := ccObj["ephemeral_1h_input_tokens"].(float64)
|
||||||
|
total := v5m + v1h
|
||||||
|
if total == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch target {
|
||||||
|
case "1h":
|
||||||
|
ccObj["ephemeral_1h_input_tokens"] = total
|
||||||
|
ccObj["ephemeral_5m_input_tokens"] = float64(0)
|
||||||
|
default: // "5m"
|
||||||
|
ccObj["ephemeral_5m_input_tokens"] = total
|
||||||
|
ccObj["ephemeral_1h_input_tokens"] = float64(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4448,6 +4553,14 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
|||||||
return nil, fmt.Errorf("parse response: %w", err)
|
return nil, fmt.Errorf("parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||||
|
cc5m := gjson.GetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens")
|
||||||
|
cc1h := gjson.GetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens")
|
||||||
|
if cc5m.Exists() || cc1h.Exists() {
|
||||||
|
response.Usage.CacheCreation5mTokens = int(cc5m.Int())
|
||||||
|
response.Usage.CacheCreation1hTokens = int(cc1h.Int())
|
||||||
|
}
|
||||||
|
|
||||||
// 兼容 Kimi cached_tokens → cache_read_input_tokens
|
// 兼容 Kimi cached_tokens → cache_read_input_tokens
|
||||||
if response.Usage.CacheReadInputTokens == 0 {
|
if response.Usage.CacheReadInputTokens == 0 {
|
||||||
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
|
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
|
||||||
@@ -4459,6 +4572,20 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
|
||||||
|
if account.IsCacheTTLOverrideEnabled() {
|
||||||
|
overrideTarget := account.GetCacheTTLOverrideTarget()
|
||||||
|
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
|
||||||
|
// 同步更新 body JSON 中的嵌套 cache_creation 对象
|
||||||
|
if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_5m_input_tokens", response.Usage.CacheCreation5mTokens); err == nil {
|
||||||
|
body = newBody
|
||||||
|
}
|
||||||
|
if newBody, err := sjson.SetBytes(body, "usage.cache_creation.ephemeral_1h_input_tokens", response.Usage.CacheCreation1hTokens); err == nil {
|
||||||
|
body = newBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有模型映射,替换响应中的model字段
|
// 如果有模型映射,替换响应中的model字段
|
||||||
if originalModel != mappedModel {
|
if originalModel != mappedModel {
|
||||||
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
||||||
@@ -4535,6 +4662,13 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
result.Usage.InputTokens = 0
|
result.Usage.InputTokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
|
||||||
|
cacheTTLOverridden := false
|
||||||
|
if account.IsCacheTTLOverrideEnabled() {
|
||||||
|
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
|
||||||
|
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
||||||
multiplier := s.cfg.Default.RateMultiplier
|
multiplier := s.cfg.Default.RateMultiplier
|
||||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||||
@@ -4565,10 +4699,12 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
} else {
|
} else {
|
||||||
// Token 计费
|
// Token 计费
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
InputTokens: result.Usage.InputTokens,
|
InputTokens: result.Usage.InputTokens,
|
||||||
OutputTokens: result.Usage.OutputTokens,
|
OutputTokens: result.Usage.OutputTokens,
|
||||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||||
|
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||||
|
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier)
|
cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier)
|
||||||
@@ -4602,6 +4738,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
OutputTokens: result.Usage.OutputTokens,
|
OutputTokens: result.Usage.OutputTokens,
|
||||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||||
|
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||||
|
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||||
InputCost: cost.InputCost,
|
InputCost: cost.InputCost,
|
||||||
OutputCost: cost.OutputCost,
|
OutputCost: cost.OutputCost,
|
||||||
CacheCreationCost: cost.CacheCreationCost,
|
CacheCreationCost: cost.CacheCreationCost,
|
||||||
@@ -4616,6 +4754,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
FirstTokenMs: result.FirstTokenMs,
|
FirstTokenMs: result.FirstTokenMs,
|
||||||
ImageCount: result.ImageCount,
|
ImageCount: result.ImageCount,
|
||||||
ImageSize: imageSize,
|
ImageSize: imageSize,
|
||||||
|
CacheTTLOverridden: cacheTTLOverridden,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4716,6 +4855,13 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
|||||||
result.Usage.InputTokens = 0
|
result.Usage.InputTokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
|
||||||
|
cacheTTLOverridden := false
|
||||||
|
if account.IsCacheTTLOverrideEnabled() {
|
||||||
|
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
|
||||||
|
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
||||||
multiplier := s.cfg.Default.RateMultiplier
|
multiplier := s.cfg.Default.RateMultiplier
|
||||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||||
@@ -4746,10 +4892,12 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
|||||||
} else {
|
} else {
|
||||||
// Token 计费(使用长上下文计费方法)
|
// Token 计费(使用长上下文计费方法)
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
InputTokens: result.Usage.InputTokens,
|
InputTokens: result.Usage.InputTokens,
|
||||||
OutputTokens: result.Usage.OutputTokens,
|
OutputTokens: result.Usage.OutputTokens,
|
||||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||||
|
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||||
|
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
cost, err = s.billingService.CalculateCostWithLongContext(result.Model, tokens, multiplier, input.LongContextThreshold, input.LongContextMultiplier)
|
cost, err = s.billingService.CalculateCostWithLongContext(result.Model, tokens, multiplier, input.LongContextThreshold, input.LongContextMultiplier)
|
||||||
@@ -4783,6 +4931,8 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
|||||||
OutputTokens: result.Usage.OutputTokens,
|
OutputTokens: result.Usage.OutputTokens,
|
||||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||||
|
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||||
|
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||||
InputCost: cost.InputCost,
|
InputCost: cost.InputCost,
|
||||||
OutputCost: cost.OutputCost,
|
OutputCost: cost.OutputCost,
|
||||||
CacheCreationCost: cost.CacheCreationCost,
|
CacheCreationCost: cost.CacheCreationCost,
|
||||||
@@ -4797,6 +4947,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
|||||||
FirstTokenMs: result.FirstTokenMs,
|
FirstTokenMs: result.FirstTokenMs,
|
||||||
ImageCount: result.ImageCount,
|
ImageCount: result.ImageCount,
|
||||||
ImageSize: imageSize,
|
ImageSize: imageSize,
|
||||||
|
CacheTTLOverridden: cacheTTLOverridden,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5102,7 +5253,8 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
|
|
||||||
incomingBeta := req.Header.Get("anthropic-beta")
|
incomingBeta := req.Header.Get("anthropic-beta")
|
||||||
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
||||||
req.Header.Set("anthropic-beta", mergeAnthropicBeta(requiredBetas, incomingBeta))
|
drop := map[string]struct{}{claude.BetaContext1M: {}}
|
||||||
|
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
||||||
} else {
|
} else {
|
||||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||||
if clientBetaHeader == "" {
|
if clientBetaHeader == "" {
|
||||||
@@ -5112,7 +5264,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
||||||
beta = beta + "," + claude.BetaTokenCounting
|
beta = beta + "," + claude.BetaTokenCounting
|
||||||
}
|
}
|
||||||
req.Header.Set("anthropic-beta", beta)
|
req.Header.Set("anthropic-beta", stripBetaToken(beta, claude.BetaContext1M))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
|
|||||||
@@ -2663,11 +2663,12 @@ func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage {
|
|||||||
prompt, _ := asInt(usageMeta["promptTokenCount"])
|
prompt, _ := asInt(usageMeta["promptTokenCount"])
|
||||||
cand, _ := asInt(usageMeta["candidatesTokenCount"])
|
cand, _ := asInt(usageMeta["candidatesTokenCount"])
|
||||||
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
|
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
|
||||||
|
thoughts, _ := asInt(usageMeta["thoughtsTokenCount"])
|
||||||
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
|
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
|
||||||
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
|
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
|
||||||
return &ClaudeUsage{
|
return &ClaudeUsage{
|
||||||
InputTokens: prompt - cached,
|
InputTokens: prompt - cached,
|
||||||
OutputTokens: cand,
|
OutputTokens: cand + thoughts,
|
||||||
CacheReadInputTokens: cached,
|
CacheReadInputTokens: cached,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
|
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
|
||||||
@@ -203,3 +205,70 @@ func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing
|
|||||||
t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s)
|
t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractGeminiUsage_ThoughtsTokenCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resp map[string]any
|
||||||
|
wantInput int
|
||||||
|
wantOutput int
|
||||||
|
wantCacheRead int
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with thoughtsTokenCount",
|
||||||
|
resp: map[string]any{
|
||||||
|
"usageMetadata": map[string]any{
|
||||||
|
"promptTokenCount": float64(100),
|
||||||
|
"candidatesTokenCount": float64(20),
|
||||||
|
"thoughtsTokenCount": float64(50),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantInput: 100,
|
||||||
|
wantOutput: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with thoughtsTokenCount and cache",
|
||||||
|
resp: map[string]any{
|
||||||
|
"usageMetadata": map[string]any{
|
||||||
|
"promptTokenCount": float64(100),
|
||||||
|
"candidatesTokenCount": float64(20),
|
||||||
|
"cachedContentTokenCount": float64(30),
|
||||||
|
"thoughtsTokenCount": float64(50),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantInput: 70,
|
||||||
|
wantOutput: 70,
|
||||||
|
wantCacheRead: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without thoughtsTokenCount (old model)",
|
||||||
|
resp: map[string]any{
|
||||||
|
"usageMetadata": map[string]any{
|
||||||
|
"promptTokenCount": float64(100),
|
||||||
|
"candidatesTokenCount": float64(20),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantInput: 100,
|
||||||
|
wantOutput: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no usageMetadata",
|
||||||
|
resp: map[string]any{},
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
usage := extractGeminiUsage(tt.resp)
|
||||||
|
if tt.wantNil {
|
||||||
|
require.Nil(t, usage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, tt.wantInput, usage.InputTokens)
|
||||||
|
require.Equal(t, tt.wantOutput, usage.OutputTokens)
|
||||||
|
require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error
|
|||||||
func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForGemini) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
||||||
|
|||||||
@@ -112,13 +112,19 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool) codexTran
|
|||||||
result.Modified = true
|
result.Modified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := reqBody["max_output_tokens"]; ok {
|
// Strip parameters unsupported by codex models via the Responses API.
|
||||||
delete(reqBody, "max_output_tokens")
|
for _, key := range []string{
|
||||||
result.Modified = true
|
"max_output_tokens",
|
||||||
}
|
"max_completion_tokens",
|
||||||
if _, ok := reqBody["max_completion_tokens"]; ok {
|
"temperature",
|
||||||
delete(reqBody, "max_completion_tokens")
|
"top_p",
|
||||||
result.Modified = true
|
"frequency_penalty",
|
||||||
|
"presence_penalty",
|
||||||
|
} {
|
||||||
|
if _, ok := reqBody[key]; ok {
|
||||||
|
delete(reqBody, key)
|
||||||
|
result.Modified = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalizeCodexTools(reqBody) {
|
if normalizeCodexTools(reqBody) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (s *OpsService) listAllAccountsForOps(ctx context.Context, platformFilter s
|
|||||||
accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{
|
accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: opsAccountsPageSize,
|
PageSize: opsAccountsPageSize,
|
||||||
}, platformFilter, "", "", "")
|
}, platformFilter, "", "", "", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ var (
|
|||||||
// LiteLLMModelPricing LiteLLM价格数据结构
|
// LiteLLMModelPricing LiteLLM价格数据结构
|
||||||
// 只保留我们需要的字段,使用指针来处理可能缺失的值
|
// 只保留我们需要的字段,使用指针来处理可能缺失的值
|
||||||
type LiteLLMModelPricing struct {
|
type LiteLLMModelPricing struct {
|
||||||
InputCostPerToken float64 `json:"input_cost_per_token"`
|
InputCostPerToken float64 `json:"input_cost_per_token"`
|
||||||
OutputCostPerToken float64 `json:"output_cost_per_token"`
|
OutputCostPerToken float64 `json:"output_cost_per_token"`
|
||||||
CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"`
|
CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"`
|
||||||
CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"`
|
CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"`
|
||||||
LiteLLMProvider string `json:"litellm_provider"`
|
CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"`
|
||||||
Mode string `json:"mode"`
|
LiteLLMProvider string `json:"litellm_provider"`
|
||||||
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
Mode string `json:"mode"`
|
||||||
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
|
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
||||||
|
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
|
||||||
}
|
}
|
||||||
|
|
||||||
// PricingRemoteClient 远程价格数据获取接口
|
// PricingRemoteClient 远程价格数据获取接口
|
||||||
@@ -45,14 +46,15 @@ type PricingRemoteClient interface {
|
|||||||
|
|
||||||
// LiteLLMRawEntry 用于解析原始JSON数据
|
// LiteLLMRawEntry 用于解析原始JSON数据
|
||||||
type LiteLLMRawEntry struct {
|
type LiteLLMRawEntry struct {
|
||||||
InputCostPerToken *float64 `json:"input_cost_per_token"`
|
InputCostPerToken *float64 `json:"input_cost_per_token"`
|
||||||
OutputCostPerToken *float64 `json:"output_cost_per_token"`
|
OutputCostPerToken *float64 `json:"output_cost_per_token"`
|
||||||
CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"`
|
CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"`
|
||||||
CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
|
CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"`
|
||||||
LiteLLMProvider string `json:"litellm_provider"`
|
CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"`
|
||||||
Mode string `json:"mode"`
|
LiteLLMProvider string `json:"litellm_provider"`
|
||||||
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
Mode string `json:"mode"`
|
||||||
OutputCostPerImage *float64 `json:"output_cost_per_image"`
|
SupportsPromptCaching bool `json:"supports_prompt_caching"`
|
||||||
|
OutputCostPerImage *float64 `json:"output_cost_per_image"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PricingService 动态价格服务
|
// PricingService 动态价格服务
|
||||||
@@ -318,6 +320,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
|||||||
if entry.CacheCreationInputTokenCost != nil {
|
if entry.CacheCreationInputTokenCost != nil {
|
||||||
pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost
|
pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost
|
||||||
}
|
}
|
||||||
|
if entry.CacheCreationInputTokenCostAbove1hr != nil {
|
||||||
|
pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr
|
||||||
|
}
|
||||||
if entry.CacheReadInputTokenCost != nil {
|
if entry.CacheReadInputTokenCost != nil {
|
||||||
pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
|
pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,10 +381,31 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试从响应头解析重置时间(Anthropic)
|
// 2. Anthropic 平台:尝试解析 per-window 头(5h / 7d),选择实际触发的窗口
|
||||||
|
if result := calculateAnthropic429ResetTime(headers); result != nil {
|
||||||
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, result.resetAt); err != nil {
|
||||||
|
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 session window:优先使用 5h-reset 头精确计算,否则从 resetAt 反推
|
||||||
|
windowEnd := result.resetAt
|
||||||
|
if result.fiveHourReset != nil {
|
||||||
|
windowEnd = *result.fiveHourReset
|
||||||
|
}
|
||||||
|
windowStart := windowEnd.Add(-5 * time.Hour)
|
||||||
|
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, &windowStart, &windowEnd, "rejected"); err != nil {
|
||||||
|
slog.Warn("rate_limit_update_session_window_failed", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("anthropic_account_rate_limited", "account_id", account.ID, "reset_at", result.resetAt, "reset_in", time.Until(result.resetAt).Truncate(time.Second))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试从响应头解析重置时间(Anthropic 聚合头,向后兼容)
|
||||||
resetTimestamp := headers.Get("anthropic-ratelimit-unified-reset")
|
resetTimestamp := headers.Get("anthropic-ratelimit-unified-reset")
|
||||||
|
|
||||||
// 3. 如果响应头没有,尝试从响应体解析(OpenAI usage_limit_reached, Gemini)
|
// 4. 如果响应头没有,尝试从响应体解析(OpenAI usage_limit_reached, Gemini)
|
||||||
if resetTimestamp == "" {
|
if resetTimestamp == "" {
|
||||||
switch account.Platform {
|
switch account.Platform {
|
||||||
case PlatformOpenAI:
|
case PlatformOpenAI:
|
||||||
@@ -497,6 +518,112 @@ func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *tim
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anthropic429Result holds the parsed Anthropic 429 rate-limit information.
|
||||||
|
type anthropic429Result struct {
|
||||||
|
resetAt time.Time // The correct reset time to use for SetRateLimited
|
||||||
|
fiveHourReset *time.Time // 5h window reset timestamp (for session window calculation), nil if not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateAnthropic429ResetTime parses Anthropic's per-window rate-limit headers
|
||||||
|
// to determine which window (5h or 7d) actually triggered the 429.
|
||||||
|
//
|
||||||
|
// Headers used:
|
||||||
|
// - anthropic-ratelimit-unified-5h-utilization / anthropic-ratelimit-unified-5h-surpassed-threshold
|
||||||
|
// - anthropic-ratelimit-unified-5h-reset
|
||||||
|
// - anthropic-ratelimit-unified-7d-utilization / anthropic-ratelimit-unified-7d-surpassed-threshold
|
||||||
|
// - anthropic-ratelimit-unified-7d-reset
|
||||||
|
//
|
||||||
|
// Returns nil when the per-window headers are absent (caller should fall back to
|
||||||
|
// the aggregated anthropic-ratelimit-unified-reset header).
|
||||||
|
func calculateAnthropic429ResetTime(headers http.Header) *anthropic429Result {
|
||||||
|
reset5hStr := headers.Get("anthropic-ratelimit-unified-5h-reset")
|
||||||
|
reset7dStr := headers.Get("anthropic-ratelimit-unified-7d-reset")
|
||||||
|
|
||||||
|
if reset5hStr == "" && reset7dStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reset5h, reset7d *time.Time
|
||||||
|
if ts, err := strconv.ParseInt(reset5hStr, 10, 64); err == nil {
|
||||||
|
t := time.Unix(ts, 0)
|
||||||
|
reset5h = &t
|
||||||
|
}
|
||||||
|
if ts, err := strconv.ParseInt(reset7dStr, 10, 64); err == nil {
|
||||||
|
t := time.Unix(ts, 0)
|
||||||
|
reset7d = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
is5hExceeded := isAnthropicWindowExceeded(headers, "5h")
|
||||||
|
is7dExceeded := isAnthropicWindowExceeded(headers, "7d")
|
||||||
|
|
||||||
|
slog.Info("anthropic_429_window_analysis",
|
||||||
|
"is_5h_exceeded", is5hExceeded,
|
||||||
|
"is_7d_exceeded", is7dExceeded,
|
||||||
|
"reset_5h", reset5hStr,
|
||||||
|
"reset_7d", reset7dStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Select the correct reset time based on which window(s) are exceeded.
|
||||||
|
var chosen *time.Time
|
||||||
|
switch {
|
||||||
|
case is5hExceeded && is7dExceeded:
|
||||||
|
// Both exceeded → prefer 7d (longer cooldown), fall back to 5h
|
||||||
|
chosen = reset7d
|
||||||
|
if chosen == nil {
|
||||||
|
chosen = reset5h
|
||||||
|
}
|
||||||
|
case is5hExceeded:
|
||||||
|
chosen = reset5h
|
||||||
|
case is7dExceeded:
|
||||||
|
chosen = reset7d
|
||||||
|
default:
|
||||||
|
// Neither flag clearly exceeded — pick the sooner reset as best guess
|
||||||
|
chosen = pickSooner(reset5h, reset7d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chosen == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &anthropic429Result{resetAt: *chosen, fiveHourReset: reset5h}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAnthropicWindowExceeded checks whether a given Anthropic rate-limit window
|
||||||
|
// (e.g. "5h" or "7d") has been exceeded, using utilization and surpassed-threshold headers.
|
||||||
|
func isAnthropicWindowExceeded(headers http.Header, window string) bool {
|
||||||
|
prefix := "anthropic-ratelimit-unified-" + window + "-"
|
||||||
|
|
||||||
|
// Check surpassed-threshold first (most explicit signal)
|
||||||
|
if st := headers.Get(prefix + "surpassed-threshold"); strings.EqualFold(st, "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to utilization >= 1.0
|
||||||
|
if utilStr := headers.Get(prefix + "utilization"); utilStr != "" {
|
||||||
|
if util, err := strconv.ParseFloat(utilStr, 64); err == nil && util >= 1.0-1e-9 {
|
||||||
|
// Use a small epsilon to handle floating point: treat 0.9999999... as >= 1.0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSooner returns whichever of the two time pointers is earlier.
|
||||||
|
// If only one is non-nil, it is returned. If both are nil, returns nil.
|
||||||
|
func pickSooner(a, b *time.Time) *time.Time {
|
||||||
|
switch {
|
||||||
|
case a != nil && b != nil:
|
||||||
|
if a.Before(*b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
case a != nil:
|
||||||
|
return a
|
||||||
|
default:
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
|
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
|
||||||
// OpenAI 的 usage_limit_reached 错误格式:
|
// OpenAI 的 usage_limit_reached 错误格式:
|
||||||
//
|
//
|
||||||
|
|||||||
202
backend/internal/service/ratelimit_service_anthropic_test.go
Normal file
202
backend/internal/service/ratelimit_service_anthropic_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_Only5hExceeded(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.02")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.32")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1770998400)
|
||||||
|
|
||||||
|
if result.fiveHourReset == nil || !result.fiveHourReset.Equal(time.Unix(1770998400, 0)) {
|
||||||
|
t.Errorf("expected fiveHourReset=1770998400, got %v", result.fiveHourReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_Only7dExceeded(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.50")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.05")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1771549200)
|
||||||
|
|
||||||
|
// fiveHourReset should still be populated for session window calculation
|
||||||
|
if result.fiveHourReset == nil || !result.fiveHourReset.Equal(time.Unix(1770998400, 0)) {
|
||||||
|
t.Errorf("expected fiveHourReset=1770998400, got %v", result.fiveHourReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_BothExceeded(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.10")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.02")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1771549200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_NoPerWindowHeaders(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil result when no per-window headers, got resetAt=%v", result.resetAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_NoHeaders(t *testing.T) {
|
||||||
|
result := calculateAnthropic429ResetTime(http.Header{})
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("expected nil result for empty headers, got resetAt=%v", result.resetAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_SurpassedThreshold(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-surpassed-threshold", "true")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-surpassed-threshold", "false")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1770998400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_UtilizationExactlyOne(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.5")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1770998400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_NeitherExceeded_UsesShorter(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.95")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400") // sooner
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.80")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200") // later
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1770998400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_Only5hResetHeader(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.05")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1770998400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateAnthropic429ResetTime_Only7dResetHeader(t *testing.T) {
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.03")
|
||||||
|
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||||
|
|
||||||
|
result := calculateAnthropic429ResetTime(headers)
|
||||||
|
assertAnthropicResult(t, result, 1771549200)
|
||||||
|
|
||||||
|
if result.fiveHourReset != nil {
|
||||||
|
t.Errorf("expected fiveHourReset=nil when no 5h headers, got %v", result.fiveHourReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAnthropicWindowExceeded(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers http.Header
|
||||||
|
window string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "utilization above 1.0",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "1.02"),
|
||||||
|
window: "5h",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utilization exactly 1.0",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "1.0"),
|
||||||
|
window: "5h",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utilization below 1.0",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "0.99"),
|
||||||
|
window: "5h",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surpassed-threshold true",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "true"),
|
||||||
|
window: "7d",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surpassed-threshold True (case insensitive)",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "True"),
|
||||||
|
window: "7d",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "surpassed-threshold false",
|
||||||
|
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "false"),
|
||||||
|
window: "7d",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no headers",
|
||||||
|
headers: http.Header{},
|
||||||
|
window: "5h",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := isAnthropicWindowExceeded(tc.headers, tc.window)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("expected %v, got %v", tc.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertAnthropicResult is a test helper that verifies the result is non-nil and
|
||||||
|
// has the expected resetAt unix timestamp.
|
||||||
|
func assertAnthropicResult(t *testing.T, result *anthropic429Result, wantUnix int64) {
|
||||||
|
t.Helper()
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
return // unreachable, but satisfies staticcheck SA5011
|
||||||
|
}
|
||||||
|
want := time.Unix(wantUnix, 0)
|
||||||
|
if !result.resetAt.Equal(want) {
|
||||||
|
t.Errorf("expected resetAt=%v, got %v", want, result.resetAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeHeader(key, value string) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set(key, value)
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ type UsageLog struct {
|
|||||||
CacheCreationTokens int
|
CacheCreationTokens int
|
||||||
CacheReadTokens int
|
CacheReadTokens int
|
||||||
|
|
||||||
CacheCreation5mTokens int
|
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
|
||||||
CacheCreation1hTokens int
|
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
|
||||||
|
|
||||||
InputCost float64
|
InputCost float64
|
||||||
OutputCost float64
|
OutputCost float64
|
||||||
@@ -46,6 +46,9 @@ type UsageLog struct {
|
|||||||
UserAgent *string
|
UserAgent *string
|
||||||
IPAddress *string
|
IPAddress *string
|
||||||
|
|
||||||
|
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
|
||||||
|
CacheTTLOverridden bool
|
||||||
|
|
||||||
// 图片生成字段
|
// 图片生成字段
|
||||||
ImageCount int
|
ImageCount int
|
||||||
ImageSize *string
|
ImageSize *string
|
||||||
|
|||||||
14
backend/migrations/054_drop_legacy_cache_columns.sql
Normal file
14
backend/migrations/054_drop_legacy_cache_columns.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Drop legacy cache token columns that lack the underscore separator.
|
||||||
|
-- These were created by GORM's automatic snake_case conversion:
|
||||||
|
-- CacheCreation5mTokens → cache_creation5m_tokens (incorrect)
|
||||||
|
-- CacheCreation1hTokens → cache_creation1h_tokens (incorrect)
|
||||||
|
--
|
||||||
|
-- The canonical columns are:
|
||||||
|
-- cache_creation_5m_tokens (defined in 001_init.sql)
|
||||||
|
-- cache_creation_1h_tokens (defined in 001_init.sql)
|
||||||
|
--
|
||||||
|
-- Migration 009 already copied data from legacy → canonical columns.
|
||||||
|
-- This migration drops the legacy columns to avoid confusion.
|
||||||
|
|
||||||
|
ALTER TABLE usage_logs DROP COLUMN IF EXISTS cache_creation5m_tokens;
|
||||||
|
ALTER TABLE usage_logs DROP COLUMN IF EXISTS cache_creation1h_tokens;
|
||||||
2
backend/migrations/055_add_cache_ttl_overridden.sql
Normal file
2
backend/migrations/055_add_cache_ttl_overridden.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add cache_ttl_overridden flag to usage_logs for tracking cache TTL override per account.
|
||||||
|
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS cache_ttl_overridden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -158,6 +158,7 @@ services:
|
|||||||
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
|
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
|
||||||
|
- PGDATA=/var/lib/postgresql/data
|
||||||
- TZ=${TZ:-Asia/Shanghai}
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
networks:
|
networks:
|
||||||
- sub2api-network
|
- sub2api-network
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function list(
|
|||||||
platform?: string
|
platform?: string
|
||||||
type?: string
|
type?: string
|
||||||
status?: string
|
status?: string
|
||||||
|
group?: string
|
||||||
search?: string
|
search?: string
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ export async function exchangeCode(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { generateAuthUrl, exchangeCode }
|
export async function refreshAntigravityToken(
|
||||||
|
refreshToken: string,
|
||||||
|
proxyId?: number | null
|
||||||
|
): Promise<AntigravityTokenInfo> {
|
||||||
|
const payload: Record<string, any> = { refresh_token: refreshToken }
|
||||||
|
if (proxyId) payload.proxy_id = proxyId
|
||||||
|
|
||||||
|
const { data } = await apiClient.post<AntigravityTokenInfo>(
|
||||||
|
'/admin/antigravity/oauth/refresh-token',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
|
{{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@click="showPopover = false"
|
@click="showPopover = false"
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ const groupIds = ref<number[]>([])
|
|||||||
// All models list (combined Anthropic + OpenAI)
|
// All models list (combined Anthropic + OpenAI)
|
||||||
const allModels = [
|
const allModels = [
|
||||||
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||||||
|
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
|
||||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||||
@@ -754,6 +755,13 @@ const presetMappings = [
|
|||||||
color:
|
color:
|
||||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Sonnet 4.6',
|
||||||
|
from: 'claude-sonnet-4-6',
|
||||||
|
to: 'claude-sonnet-4-6',
|
||||||
|
color:
|
||||||
|
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Opus->Sonnet',
|
label: 'Opus->Sonnet',
|
||||||
from: 'claude-opus-4-5-20251101',
|
from: 'claude-opus-4-5-20251101',
|
||||||
|
|||||||
@@ -1527,6 +1527,46 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache TTL Override -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.cacheTTLOverride.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.cacheTTLOverride.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="cacheTTLOverrideEnabled = !cacheTTLOverrideEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
cacheTTLOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
cacheTTLOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="cacheTTLOverrideEnabled" class="mt-3">
|
||||||
|
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.cacheTTLOverride.target') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="cacheTTLOverrideTarget"
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="5m">5m</option>
|
||||||
|
<option value="1h">1h</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.cacheTTLOverride.targetHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1647,12 +1687,12 @@
|
|||||||
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
||||||
:allow-multiple="form.platform === 'anthropic'"
|
:allow-multiple="form.platform === 'anthropic'"
|
||||||
:show-cookie-option="form.platform === 'anthropic'"
|
:show-cookie-option="form.platform === 'anthropic'"
|
||||||
:show-refresh-token-option="form.platform === 'openai'"
|
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||||
@generate-url="handleGenerateUrl"
|
@generate-url="handleGenerateUrl"
|
||||||
@cookie-auth="handleCookieAuth"
|
@cookie-auth="handleCookieAuth"
|
||||||
@validate-refresh-token="handleOpenAIValidateRT"
|
@validate-refresh-token="handleValidateRefreshToken"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -2146,6 +2186,8 @@ const maxSessions = ref<number | null>(null)
|
|||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
const tlsFingerprintEnabled = ref(false)
|
const tlsFingerprintEnabled = ref(false)
|
||||||
const sessionIdMaskingEnabled = ref(false)
|
const sessionIdMaskingEnabled = ref(false)
|
||||||
|
const cacheTTLOverrideEnabled = ref(false)
|
||||||
|
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||||
|
|
||||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||||
@@ -2597,6 +2639,8 @@ const resetForm = () => {
|
|||||||
sessionIdleTimeout.value = null
|
sessionIdleTimeout.value = null
|
||||||
tlsFingerprintEnabled.value = false
|
tlsFingerprintEnabled.value = false
|
||||||
sessionIdMaskingEnabled.value = false
|
sessionIdMaskingEnabled.value = false
|
||||||
|
cacheTTLOverrideEnabled.value = false
|
||||||
|
cacheTTLOverrideTarget.value = '5m'
|
||||||
antigravityAccountType.value = 'oauth'
|
antigravityAccountType.value = 'oauth'
|
||||||
upstreamBaseUrl.value = ''
|
upstreamBaseUrl.value = ''
|
||||||
upstreamApiKey.value = ''
|
upstreamApiKey.value = ''
|
||||||
@@ -2802,6 +2846,14 @@ const handleGenerateUrl = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleValidateRefreshToken = (rt: string) => {
|
||||||
|
if (form.platform === 'openai') {
|
||||||
|
handleOpenAIValidateRT(rt)
|
||||||
|
} else if (form.platform === 'antigravity') {
|
||||||
|
handleAntigravityValidateRT(rt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
const parseDateTimeLocal = parseDateTimeLocalInput
|
||||||
|
|
||||||
@@ -2950,6 +3002,95 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity 手动 RT 批量验证和创建
|
||||||
|
const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
|
||||||
|
if (!refreshTokenInput.trim()) return
|
||||||
|
|
||||||
|
// Parse multiple refresh tokens (one per line)
|
||||||
|
const refreshTokens = refreshTokenInput
|
||||||
|
.split('\n')
|
||||||
|
.map((rt) => rt.trim())
|
||||||
|
.filter((rt) => rt)
|
||||||
|
|
||||||
|
if (refreshTokens.length === 0) {
|
||||||
|
antigravityOAuth.error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
antigravityOAuth.loading.value = true
|
||||||
|
antigravityOAuth.error.value = ''
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < refreshTokens.length; i++) {
|
||||||
|
try {
|
||||||
|
const tokenInfo = await antigravityOAuth.validateRefreshToken(
|
||||||
|
refreshTokens[i],
|
||||||
|
form.proxy_id
|
||||||
|
)
|
||||||
|
if (!tokenInfo) {
|
||||||
|
failedCount++
|
||||||
|
errors.push(`#${i + 1}: ${antigravityOAuth.error.value || 'Validation failed'}`)
|
||||||
|
antigravityOAuth.error.value = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||||
|
|
||||||
|
// Generate account name with index for batch
|
||||||
|
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
|
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
|
||||||
|
await adminAPI.accounts.create({
|
||||||
|
name: accountName,
|
||||||
|
notes: form.notes,
|
||||||
|
platform: 'antigravity',
|
||||||
|
type: 'oauth',
|
||||||
|
credentials,
|
||||||
|
extra: {},
|
||||||
|
proxy_id: form.proxy_id,
|
||||||
|
concurrency: form.concurrency,
|
||||||
|
priority: form.priority,
|
||||||
|
rate_multiplier: form.rate_multiplier,
|
||||||
|
group_ids: form.group_ids,
|
||||||
|
expires_at: form.expires_at,
|
||||||
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
|
})
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
failedCount++
|
||||||
|
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||||
|
errors.push(`#${i + 1}: ${errMsg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
if (successCount > 0 && failedCount === 0) {
|
||||||
|
appStore.showSuccess(
|
||||||
|
refreshTokens.length > 1
|
||||||
|
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||||
|
: t('admin.accounts.accountCreated')
|
||||||
|
)
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
} else if (successCount > 0 && failedCount > 0) {
|
||||||
|
appStore.showWarning(
|
||||||
|
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||||
|
)
|
||||||
|
antigravityOAuth.error.value = errors.join('\n')
|
||||||
|
emit('created')
|
||||||
|
} else {
|
||||||
|
antigravityOAuth.error.value = errors.join('\n')
|
||||||
|
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
antigravityOAuth.loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gemini OAuth 授权码兑换
|
// Gemini OAuth 授权码兑换
|
||||||
const handleGeminiExchange = async (authCode: string) => {
|
const handleGeminiExchange = async (authCode: string) => {
|
||||||
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
||||||
@@ -3077,6 +3218,12 @@ const handleAnthropicExchange = async (authCode: string) => {
|
|||||||
extra.session_id_masking_enabled = true
|
extra.session_id_masking_enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add cache TTL override settings
|
||||||
|
if (cacheTTLOverrideEnabled.value) {
|
||||||
|
extra.cache_ttl_override_enabled = true
|
||||||
|
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
...tokenInfo,
|
...tokenInfo,
|
||||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||||
@@ -3170,6 +3317,12 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
extra.session_id_masking_enabled = true
|
extra.session_id_masking_enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add cache TTL override settings
|
||||||
|
if (cacheTTLOverrideEnabled.value) {
|
||||||
|
extra.cache_ttl_override_enabled = true
|
||||||
|
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
|
||||||
|
}
|
||||||
|
|
||||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
// Merge interceptWarmupRequests into credentials
|
// Merge interceptWarmupRequests into credentials
|
||||||
|
|||||||
@@ -904,6 +904,46 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache TTL Override -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.cacheTTLOverride.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.cacheTTLOverride.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="cacheTTLOverrideEnabled = !cacheTTLOverrideEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
cacheTTLOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
cacheTTLOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="cacheTTLOverrideEnabled" class="mt-3">
|
||||||
|
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.cacheTTLOverride.target') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="cacheTTLOverrideTarget"
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="5m">5m</option>
|
||||||
|
<option value="1h">1h</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.cacheTTLOverride.targetHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
@@ -1102,6 +1142,8 @@ const maxSessions = ref<number | null>(null)
|
|||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
const tlsFingerprintEnabled = ref(false)
|
const tlsFingerprintEnabled = ref(false)
|
||||||
const sessionIdMaskingEnabled = ref(false)
|
const sessionIdMaskingEnabled = ref(false)
|
||||||
|
const cacheTTLOverrideEnabled = ref(false)
|
||||||
|
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||||
|
|
||||||
// Computed: current preset mappings based on platform
|
// Computed: current preset mappings based on platform
|
||||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||||
@@ -1489,6 +1531,8 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
sessionIdleTimeout.value = null
|
sessionIdleTimeout.value = null
|
||||||
tlsFingerprintEnabled.value = false
|
tlsFingerprintEnabled.value = false
|
||||||
sessionIdMaskingEnabled.value = false
|
sessionIdMaskingEnabled.value = false
|
||||||
|
cacheTTLOverrideEnabled.value = false
|
||||||
|
cacheTTLOverrideTarget.value = '5m'
|
||||||
|
|
||||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||||
@@ -1517,6 +1561,12 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
if (account.session_id_masking_enabled === true) {
|
if (account.session_id_masking_enabled === true) {
|
||||||
sessionIdMaskingEnabled.value = true
|
sessionIdMaskingEnabled.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load cache TTL override setting
|
||||||
|
if (account.cache_ttl_override_enabled === true) {
|
||||||
|
cacheTTLOverrideEnabled.value = true
|
||||||
|
cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTempUnschedKeywords(value: unknown) {
|
function formatTempUnschedKeywords(value: unknown) {
|
||||||
@@ -1723,6 +1773,15 @@ const handleSubmit = async () => {
|
|||||||
delete newExtra.session_id_masking_enabled
|
delete newExtra.session_id_masking_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache TTL override setting
|
||||||
|
if (cacheTTLOverrideEnabled.value) {
|
||||||
|
newExtra.cache_ttl_override_enabled = true
|
||||||
|
newExtra.cache_ttl_override_target = cacheTTLOverrideTarget.value
|
||||||
|
} else {
|
||||||
|
delete newExtra.cache_ttl_override_enabled
|
||||||
|
delete newExtra.cache_ttl_override_target
|
||||||
|
}
|
||||||
|
|
||||||
updatePayload.extra = newExtra
|
updatePayload.extra = newExtra
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,19 +45,19 @@
|
|||||||
class="text-blue-600 focus:ring-blue-500"
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||||
t('admin.accounts.oauth.openai.refreshTokenAuth')
|
t(getOAuthKey('refreshTokenAuth'))
|
||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh Token Input (OpenAI only) -->
|
<!-- Refresh Token Input (OpenAI / Antigravity) -->
|
||||||
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
|
{{ t(getOAuthKey('refreshTokenDesc')) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Refresh Token Input -->
|
<!-- Refresh Token Input -->
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
v-model="refreshTokenInput"
|
v-model="refreshTokenInput"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="input w-full resize-y font-mono text-sm"
|
class="input w-full resize-y font-mono text-sm"
|
||||||
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
|
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p
|
<p
|
||||||
v-if="parsedRefreshTokenCount > 1"
|
v-if="parsedRefreshTokenCount > 1"
|
||||||
@@ -128,8 +128,8 @@
|
|||||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||||
{{
|
{{
|
||||||
loading
|
loading
|
||||||
? t('admin.accounts.oauth.openai.validating')
|
? t(getOAuthKey('validating'))
|
||||||
: t('admin.accounts.oauth.openai.validateAndCreate')
|
: t(getOAuthKey('validateAndCreate'))
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,16 +10,21 @@
|
|||||||
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
||||||
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||||
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||||
|
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||||
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
import type { AdminGroup } from '@/types'
|
||||||
|
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
||||||
|
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||||
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
|
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
|
||||||
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
||||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||||
|
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
||||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
|
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
|
||||||
|
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||||
|
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
|
||||||
|
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,9 +159,36 @@
|
|||||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
<!-- 有 5m/1h 明细时,展开显示 -->
|
||||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
|
||||||
|
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('admin.usage.cacheCreation5mTokens') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('admin.usage.cacheCreation1hTokens') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 无明细时,只显示聚合值 -->
|
||||||
|
<div v-else class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('usage.cacheTtlOverriddenLabel') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="stat-label truncate">{{ title }}</p>
|
<p class="stat-label truncate">{{ title }}</p>
|
||||||
<div class="mt-1 flex items-baseline gap-2">
|
<div class="mt-1 flex items-baseline gap-2">
|
||||||
<p class="stat-value">{{ formattedValue }}</p>
|
<p class="stat-value" :title="String(formattedValue)">{{ formattedValue }}</p>
|
||||||
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
||||||
<Icon
|
<Icon
|
||||||
v-if="changeType !== 'neutral'"
|
v-if="changeType !== 'neutral'"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<!-- Custom Logo or Default Logo -->
|
<!-- Custom Logo or Default Logo -->
|
||||||
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
||||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||||
@@ -167,6 +167,7 @@ const isDark = ref(document.documentElement.classList.contains('dark'))
|
|||||||
const siteName = computed(() => appStore.siteName)
|
const siteName = computed(() => appStore.siteName)
|
||||||
const siteLogo = computed(() => appStore.siteLogo)
|
const siteLogo = computed(() => appStore.siteLogo)
|
||||||
const siteVersion = computed(() => appStore.siteVersion)
|
const siteVersion = computed(() => appStore.siteVersion)
|
||||||
|
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
||||||
|
|
||||||
// SVG Icon Components
|
// SVG Icon Components
|
||||||
const DashboardIcon = {
|
const DashboardIcon = {
|
||||||
|
|||||||
@@ -29,17 +29,19 @@
|
|||||||
<!-- Logo/Brand -->
|
<!-- Logo/Brand -->
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<!-- Custom Logo or Default Logo -->
|
<!-- Custom Logo or Default Logo -->
|
||||||
<div
|
<template v-if="settingsLoaded">
|
||||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
<div
|
||||||
>
|
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
||||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
>
|
||||||
</div>
|
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
</div>
|
||||||
{{ siteName }}
|
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||||
</h1>
|
{{ siteName }}
|
||||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
</h1>
|
||||||
{{ siteSubtitle }}
|
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
</p>
|
{{ siteSubtitle }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Container -->
|
<!-- Card Container -->
|
||||||
@@ -61,25 +63,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { getPublicSettings } from '@/api/auth'
|
import { useAppStore } from '@/stores'
|
||||||
import { sanitizeUrl } from '@/utils/url'
|
import { sanitizeUrl } from '@/utils/url'
|
||||||
|
|
||||||
const siteName = ref('Sub2API')
|
const appStore = useAppStore()
|
||||||
const siteLogo = ref('')
|
|
||||||
const siteSubtitle = ref('Subscription to API Conversion Platform')
|
const siteName = computed(() => appStore.siteName || 'Sub2API')
|
||||||
|
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
|
||||||
|
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
|
||||||
|
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
||||||
|
|
||||||
const currentYear = computed(() => new Date().getFullYear())
|
const currentYear = computed(() => new Date().getFullYear())
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
try {
|
appStore.fetchPublicSettings()
|
||||||
const settings = await getPublicSettings()
|
|
||||||
siteName.value = settings.site_name || 'Sub2API'
|
|
||||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
|
||||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load public settings:', error)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validateRefreshToken = async (
|
||||||
|
refreshToken: string,
|
||||||
|
proxyId?: number | null
|
||||||
|
): Promise<AntigravityTokenInfo | null> => {
|
||||||
|
if (!refreshToken.trim()) {
|
||||||
|
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
|
||||||
|
refreshToken.trim(),
|
||||||
|
proxyId
|
||||||
|
)
|
||||||
|
return tokenInfo as AntigravityTokenInfo
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value =
|
||||||
|
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
|
||||||
|
// Don't show global error toast for batch validation to avoid spamming
|
||||||
|
// appStore.showError(error.value)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
|
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
|
||||||
let expiresAt: string | undefined
|
let expiresAt: string | undefined
|
||||||
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
||||||
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
|
|||||||
resetState,
|
resetState,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
exchangeAuthCode,
|
exchangeAuthCode,
|
||||||
|
validateRefreshToken,
|
||||||
buildCredentials
|
buildCredentials
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const claudeModels = [
|
|||||||
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
|
'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001',
|
||||||
'claude-opus-4-5-20251101',
|
'claude-opus-4-5-20251101',
|
||||||
'claude-opus-4-6',
|
'claude-opus-4-6',
|
||||||
|
'claude-sonnet-4-6',
|
||||||
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
|
'claude-2.1', 'claude-2.0', 'claude-instant-1.2'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
|
|||||||
const anthropicPresetMappings = [
|
const anthropicPresetMappings = [
|
||||||
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||||
|
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||||
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||||
|
|||||||
@@ -576,6 +576,10 @@ export default {
|
|||||||
description: 'View and analyze your API usage history',
|
description: 'View and analyze your API usage history',
|
||||||
costDetails: 'Cost Breakdown',
|
costDetails: 'Cost Breakdown',
|
||||||
tokenDetails: 'Token Breakdown',
|
tokenDetails: 'Token Breakdown',
|
||||||
|
cacheTtlOverriddenHint: 'Cache TTL Override enabled',
|
||||||
|
cacheTtlOverriddenLabel: 'TTL Override',
|
||||||
|
cacheTtlOverridden5m: 'Billed as 5m',
|
||||||
|
cacheTtlOverridden1h: 'Billed as 1h',
|
||||||
totalRequests: 'Total Requests',
|
totalRequests: 'Total Requests',
|
||||||
totalTokens: 'Total Tokens',
|
totalTokens: 'Total Tokens',
|
||||||
totalCost: 'Total Cost',
|
totalCost: 'Total Cost',
|
||||||
@@ -841,7 +845,7 @@ export default {
|
|||||||
createUser: 'Create User',
|
createUser: 'Create User',
|
||||||
editUser: 'Edit User',
|
editUser: 'Edit User',
|
||||||
deleteUser: 'Delete User',
|
deleteUser: 'Delete User',
|
||||||
searchUsers: 'Search users...',
|
searchUsers: 'Search by email, username, notes, or API key...',
|
||||||
allRoles: 'All Roles',
|
allRoles: 'All Roles',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
@@ -1335,6 +1339,7 @@ export default {
|
|||||||
allPlatforms: 'All Platforms',
|
allPlatforms: 'All Platforms',
|
||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
|
allGroups: 'All Groups',
|
||||||
oauthType: 'OAuth',
|
oauthType: 'OAuth',
|
||||||
setupToken: 'Setup Token',
|
setupToken: 'Setup Token',
|
||||||
apiKey: 'API Key',
|
apiKey: 'API Key',
|
||||||
@@ -1344,7 +1349,7 @@ export default {
|
|||||||
schedulableEnabled: 'Scheduling enabled',
|
schedulableEnabled: 'Scheduling enabled',
|
||||||
schedulableDisabled: 'Scheduling disabled',
|
schedulableDisabled: 'Scheduling disabled',
|
||||||
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
||||||
allGroups: '{count} groups total',
|
groupCountTotal: '{count} groups total',
|
||||||
platforms: {
|
platforms: {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
@@ -1594,6 +1599,12 @@ export default {
|
|||||||
sessionIdMasking: {
|
sessionIdMasking: {
|
||||||
label: 'Session ID Masking',
|
label: 'Session ID Masking',
|
||||||
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
|
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
|
||||||
|
},
|
||||||
|
cacheTTLOverride: {
|
||||||
|
label: 'Cache TTL Override',
|
||||||
|
hint: 'Force all cache creation tokens to be billed as the selected TTL tier (5m or 1h)',
|
||||||
|
target: 'Target TTL',
|
||||||
|
targetHint: 'Select the TTL tier for billing'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
@@ -1774,13 +1785,20 @@ export default {
|
|||||||
authCode: 'Authorization URL or Code',
|
authCode: 'Authorization URL or Code',
|
||||||
authCodePlaceholder:
|
authCodePlaceholder:
|
||||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||||
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
||||||
missingExchangeParams: 'Missing code, session ID, or state',
|
missingExchangeParams: 'Missing code, session ID, or state',
|
||||||
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
|
failedToExchangeCode: 'Failed to exchange Antigravity auth code',
|
||||||
}
|
// Refresh Token auth
|
||||||
},
|
refreshTokenAuth: 'Manual RT',
|
||||||
// Gemini specific (platform-wide)
|
refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.',
|
||||||
|
refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line',
|
||||||
|
validating: 'Validating...',
|
||||||
|
validateAndCreate: 'Validate & Create',
|
||||||
|
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||||
|
failedToValidateRT: 'Failed to validate Refresh Token'
|
||||||
|
}
|
||||||
|
}, // Gemini specific (platform-wide)
|
||||||
gemini: {
|
gemini: {
|
||||||
helpButton: 'Help',
|
helpButton: 'Help',
|
||||||
helpDialog: {
|
helpDialog: {
|
||||||
@@ -2129,7 +2147,7 @@ export default {
|
|||||||
title: 'Redeem Code Management',
|
title: 'Redeem Code Management',
|
||||||
description: 'Generate and manage redeem codes',
|
description: 'Generate and manage redeem codes',
|
||||||
generateCodes: 'Generate Codes',
|
generateCodes: 'Generate Codes',
|
||||||
searchCodes: 'Search codes...',
|
searchCodes: 'Search codes or email...',
|
||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
@@ -2352,6 +2370,8 @@ export default {
|
|||||||
inputTokens: 'Input Tokens',
|
inputTokens: 'Input Tokens',
|
||||||
outputTokens: 'Output Tokens',
|
outputTokens: 'Output Tokens',
|
||||||
cacheCreationTokens: 'Cache Creation Tokens',
|
cacheCreationTokens: 'Cache Creation Tokens',
|
||||||
|
cacheCreation5mTokens: 'Cache Write',
|
||||||
|
cacheCreation1hTokens: 'Cache Write',
|
||||||
cacheReadTokens: 'Cache Read Tokens',
|
cacheReadTokens: 'Cache Read Tokens',
|
||||||
failedToLoad: 'Failed to load usage records',
|
failedToLoad: 'Failed to load usage records',
|
||||||
billingType: 'Billing Type',
|
billingType: 'Billing Type',
|
||||||
|
|||||||
@@ -582,6 +582,10 @@ export default {
|
|||||||
description: '查看和分析您的 API 使用历史',
|
description: '查看和分析您的 API 使用历史',
|
||||||
costDetails: '成本明细',
|
costDetails: '成本明细',
|
||||||
tokenDetails: 'Token 明细',
|
tokenDetails: 'Token 明细',
|
||||||
|
cacheTtlOverriddenHint: '缓存 TTL Override 已启用',
|
||||||
|
cacheTtlOverriddenLabel: 'TTL 替换',
|
||||||
|
cacheTtlOverridden5m: '按 5m 计费',
|
||||||
|
cacheTtlOverridden1h: '按 1h 计费',
|
||||||
totalRequests: '总请求数',
|
totalRequests: '总请求数',
|
||||||
totalTokens: '总 Token',
|
totalTokens: '总 Token',
|
||||||
totalCost: '总消费',
|
totalCost: '总消费',
|
||||||
@@ -865,8 +869,8 @@ export default {
|
|||||||
editUser: '编辑用户',
|
editUser: '编辑用户',
|
||||||
deleteUser: '删除用户',
|
deleteUser: '删除用户',
|
||||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||||
searchPlaceholder: '搜索用户邮箱或用户名、备注、支持模糊查询...',
|
searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...',
|
||||||
searchUsers: '搜索用户邮箱或用户名、备注、支持模糊查询',
|
searchUsers: '邮箱/用户名/备注/API Key 模糊搜索',
|
||||||
roleFilter: '角色筛选',
|
roleFilter: '角色筛选',
|
||||||
allRoles: '全部角色',
|
allRoles: '全部角色',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
@@ -1426,6 +1430,7 @@ export default {
|
|||||||
allPlatforms: '全部平台',
|
allPlatforms: '全部平台',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
|
allGroups: '全部分组',
|
||||||
oauthType: 'OAuth',
|
oauthType: 'OAuth',
|
||||||
// Schedulable toggle
|
// Schedulable toggle
|
||||||
schedulable: '参与调度',
|
schedulable: '参与调度',
|
||||||
@@ -1433,7 +1438,7 @@ export default {
|
|||||||
schedulableEnabled: '调度已开启',
|
schedulableEnabled: '调度已开启',
|
||||||
schedulableDisabled: '调度已关闭',
|
schedulableDisabled: '调度已关闭',
|
||||||
failedToToggleSchedulable: '切换调度状态失败',
|
failedToToggleSchedulable: '切换调度状态失败',
|
||||||
allGroups: '共 {count} 个分组',
|
groupCountTotal: '共 {count} 个分组',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
platformType: '平台/类型',
|
platformType: '平台/类型',
|
||||||
@@ -1740,6 +1745,12 @@ export default {
|
|||||||
sessionIdMasking: {
|
sessionIdMasking: {
|
||||||
label: '会话 ID 伪装',
|
label: '会话 ID 伪装',
|
||||||
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
|
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
|
||||||
|
},
|
||||||
|
cacheTTLOverride: {
|
||||||
|
label: '缓存 TTL 强制替换',
|
||||||
|
hint: '将所有缓存创建 token 强制按指定的 TTL 类型(5分钟或1小时)计费',
|
||||||
|
target: '目标 TTL',
|
||||||
|
targetHint: '选择计费使用的 TTL 类型'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
@@ -1913,7 +1924,15 @@ export default {
|
|||||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||||
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
||||||
missingExchangeParams: '缺少 code / session_id / state',
|
missingExchangeParams: '缺少 code / session_id / state',
|
||||||
failedToExchangeCode: 'Antigravity 授权码兑换失败'
|
failedToExchangeCode: 'Antigravity 授权码兑换失败',
|
||||||
|
// Refresh Token auth
|
||||||
|
refreshTokenAuth: '手动输入 RT',
|
||||||
|
refreshTokenDesc: '输入您已有的 Antigravity Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',
|
||||||
|
refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个,每行一个',
|
||||||
|
validating: '验证中...',
|
||||||
|
validateAndCreate: '验证并创建账号',
|
||||||
|
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||||
|
failedToValidateRT: '验证 Refresh Token 失败'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Gemini specific (platform-wide)
|
// Gemini specific (platform-wide)
|
||||||
@@ -2292,7 +2311,7 @@ export default {
|
|||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
unused: '未使用',
|
unused: '未使用',
|
||||||
used: '已使用',
|
used: '已使用',
|
||||||
searchCodes: '搜索兑换码...',
|
searchCodes: '搜索兑换码或邮箱...',
|
||||||
exportCsv: '导出 CSV',
|
exportCsv: '导出 CSV',
|
||||||
deleteAllUnused: '删除全部未使用',
|
deleteAllUnused: '删除全部未使用',
|
||||||
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
||||||
@@ -2518,6 +2537,8 @@ export default {
|
|||||||
inputTokens: '输入 Token',
|
inputTokens: '输入 Token',
|
||||||
outputTokens: '输出 Token',
|
outputTokens: '输出 Token',
|
||||||
cacheCreationTokens: '缓存创建 Token',
|
cacheCreationTokens: '缓存创建 Token',
|
||||||
|
cacheCreation5mTokens: '缓存创建',
|
||||||
|
cacheCreation1hTokens: '缓存创建',
|
||||||
cacheReadTokens: '缓存读取 Token',
|
cacheReadTokens: '缓存读取 Token',
|
||||||
failedToLoad: '加载使用记录失败',
|
failedToLoad: '加载使用记录失败',
|
||||||
billingType: '计费类型',
|
billingType: '计费类型',
|
||||||
|
|||||||
@@ -243,7 +243,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
@apply text-2xl font-bold text-gray-900 dark:text-white;
|
@apply text-2xl font-bold text-gray-900 dark:text-white truncate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
|
|||||||
@@ -614,6 +614,10 @@ export interface Account {
|
|||||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||||
session_id_masking_enabled?: boolean | null
|
session_id_masking_enabled?: boolean | null
|
||||||
|
|
||||||
|
// 缓存 TTL 强制替换(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||||
|
cache_ttl_override_enabled?: boolean | null
|
||||||
|
cache_ttl_override_target?: string | null
|
||||||
|
|
||||||
// 运行时状态(仅当启用对应限制时返回)
|
// 运行时状态(仅当启用对应限制时返回)
|
||||||
current_window_cost?: number | null // 当前窗口费用
|
current_window_cost?: number | null // 当前窗口费用
|
||||||
active_sessions?: number | null // 当前活跃会话数
|
active_sessions?: number | null // 当前活跃会话数
|
||||||
@@ -827,6 +831,9 @@ export interface UsageLog {
|
|||||||
// User-Agent
|
// User-Agent
|
||||||
user_agent: string | null
|
user_agent: string | null
|
||||||
|
|
||||||
|
// Cache TTL Override
|
||||||
|
cache_ttl_overridden: boolean
|
||||||
|
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|
||||||
user?: User
|
user?: User
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
type SanitizeOptions = {
|
type SanitizeOptions = {
|
||||||
allowRelative?: boolean
|
allowRelative?: boolean
|
||||||
|
allowDataUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
||||||
@@ -18,6 +19,11 @@ export function sanitizeUrl(value: string, options: SanitizeOptions = {}): strin
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 允许 data:image/ 开头的 data URL(仅限图片类型)
|
||||||
|
if (options.allowDataUrl && trimmed.startsWith('data:image/')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
||||||
// 检查是否以 http:// 或 https:// 开头
|
// 检查是否以 http:// 或 https:// 开头
|
||||||
if (!trimmed.match(/^https?:\/\//i)) {
|
if (!trimmed.match(/^https?:\/\//i)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<AccountTableFilters
|
<AccountTableFilters
|
||||||
v-model:searchQuery="params.search"
|
v-model:searchQuery="params.search"
|
||||||
:filters="params"
|
:filters="params"
|
||||||
|
:groups="groups"
|
||||||
@update:filters="(newFilters) => Object.assign(params, newFilters)"
|
@update:filters="(newFilters) => Object.assign(params, newFilters)"
|
||||||
@change="debouncedReload"
|
@change="debouncedReload"
|
||||||
@update:searchQuery="debouncedReload"
|
@update:searchQuery="debouncedReload"
|
||||||
@@ -439,7 +440,7 @@ const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
|||||||
|
|
||||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||||
fetchFn: adminAPI.accounts.list,
|
fetchFn: adminAPI.accounts.list,
|
||||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const isAnyModalOpen = computed(() => {
|
const isAnyModalOpen = computed(() => {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-used_by="{ value }">
|
<template #cell-used_by="{ value, row }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
|
{{ row.user?.email || (value ? t('admin.redeem.userPrefix', { id: value }) : '-') }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,8 @@
|
|||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||||
formatCacheTokens(row.cache_creation_tokens)
|
formatCacheTokens(row.cache_creation_tokens)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span v-if="row.cache_creation_1h_tokens > 0" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-100 text-orange-600 ring-1 ring-inset ring-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:ring-orange-500/30">1h</span>
|
||||||
|
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,9 +352,36 @@
|
|||||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0">
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
<!-- 有 5m/1h 明细时,展开显示 -->
|
||||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
<template v-if="tokenTooltipData.cache_creation_5m_tokens > 0 || tokenTooltipData.cache_creation_1h_tokens > 0">
|
||||||
|
<div v-if="tokenTooltipData.cache_creation_5m_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('admin.usage.cacheCreation5mTokens') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-amber-500/20 text-amber-400 ring-1 ring-inset ring-amber-500/30">5m</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_5m_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData.cache_creation_1h_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('admin.usage.cacheCreation1hTokens') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-orange-500/20 text-orange-400 ring-1 ring-inset ring-orange-500/30">1h</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_1h_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 无明细时,只显示聚合值 -->
|
||||||
|
<div v-else class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_ttl_overridden" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400 flex items-center gap-1.5">
|
||||||
|
{{ t('usage.cacheTtlOverriddenLabel') }}
|
||||||
|
<span class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-500/20 text-rose-400 ring-1 ring-inset ring-rose-500/30">R-{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? '5m' : '1H' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-rose-400">{{ tokenTooltipData.cache_creation_1h_tokens > 0 ? t('usage.cacheTtlOverridden1h') : t('usage.cacheTtlOverridden5m') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user