diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30e37c585..1b9f7d010 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,14 +6,17 @@ on: - unstable - development - 'release/**' + - branch-2 pull_request: branches: - unstable - development - 'release/**' + - branch-2 env: GO111MODULE: on + GOTOOLCHAIN: auto jobs: build-backend: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index adbcc2e75..7dd375ebd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,14 +4,17 @@ name: Lint on: push: branches: + - 'branch-2' - 'unstable' - 'release/**' pull_request: branches: + - 'branch-2' - 'unstable' - 'release/**' env: GO111MODULE: on + GOTOOLCHAIN: auto jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 567330c16..17659c874 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,14 +4,17 @@ name: Test on: push: branches: + - "branch-2" - "unstable" - "release/**" pull_request: branches: + - "branch-2" - "unstable" - "release/**" env: GO111MODULE: on + GOTOOLCHAIN: auto jobs: test: runs-on: ubuntu-latest diff --git a/internal/authz/system.go b/internal/authz/system.go index a95dfad33..75112f263 100644 --- a/internal/authz/system.go +++ b/internal/authz/system.go @@ -10,9 +10,8 @@ func NewSystemContext(ctx context.Context) context.Context { return context.WithValue(ctx, principalKey{}, Principal{Type: PrincipalTypeSystem}) } -func WithSystemBypass(ctx context.Context, reason string) context.Context { - bypassCtx, _ := WithBypassPrivacy(NewSystemContext(ctx), reason) - return bypassCtx +func WithSystemBypass(ctx context.Context, reason string) (context.Context, error) { + return WithBypassPrivacy(NewSystemContext(ctx), reason) } func RunWithSystemBypass[T any](ctx context.Context, reason string, fn func(ctx context.Context) (T, error)) (T, error) { diff --git a/internal/dumper/dumper.go b/internal/dumper/dumper.go index 8bbf25332..16457120c 100644 --- a/internal/dumper/dumper.go +++ b/internal/dumper/dumper.go @@ -6,8 +6,10 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" + "sort" "sync" "time" @@ -72,7 +74,9 @@ func (d *Dumper) DumpStruct(ctx context.Context, data any, filename string) { return } - fmt.Printf("Successfully dumped struct to file: %s\n", fullPath) + d.cleanup() + + slog.Info("dumped struct to file", "path", fullPath) } // DumpStreamEvents dumps a slice of interface{} as JSONL (JSON Lines) to a file. @@ -130,7 +134,9 @@ func (d *Dumper) DumpStreamEvents(ctx context.Context, events []*httpclient.Stre } } - fmt.Printf("Successfully dumped stream events to file: %s (count: %d)\n", fullPath, len(events)) + d.cleanup() + + slog.Info("dumped stream events to file", "path", fullPath, "count", len(events)) } // DumpBytes dumps raw byte data to a file. @@ -171,5 +177,92 @@ func (d *Dumper) DumpBytes(ctx context.Context, data []byte, filename string) { return } - fmt.Printf("Successfully dumped bytes to file: %s (size: %d)\n", fullPath, len(data)) + d.cleanup() + + slog.Info("dumped bytes to file", "path", fullPath, "size", len(data)) +} + +// cleanup enforces retention policy on dump files: total size, backup count, and max age. +func (d *Dumper) cleanup() { + entries, err := os.ReadDir(d.config.DumpPath) + if err != nil { + return + } + + type fileEntry struct { + path string + modTime time.Time + size int64 + } + + var files []fileEntry + var totalSize int64 + maxBytes := int64(d.config.MaxSize) * 1024 * 1024 + cutoff := time.Now().Add(-d.config.MaxAge) + + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileEntry{ + path: filepath.Join(d.config.DumpPath, e.Name()), + modTime: info.ModTime(), + size: info.Size(), + }) + totalSize += info.Size() + } + + // Sort by modification time, oldest first. + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.Before(files[j].modTime) + }) + + toDelete := make(map[string]struct{}) + + // 1. Remove files exceeding MaxAge. + for _, f := range files { + if d.config.MaxAge > 0 && f.modTime.Before(cutoff) { + toDelete[f.path] = struct{}{} + } + } + + // 2. Remove oldest files until total size is within MaxSize. + if maxBytes > 0 { + remaining := totalSize + for _, f := range files { + if _, ok := toDelete[f.path]; ok { + continue + } + if remaining <= maxBytes { + break + } + toDelete[f.path] = struct{}{} + remaining -= f.size + } + } + + // 3. Remove oldest files if count exceeds MaxBackups. + if d.config.MaxBackups > 0 { + kept := 0 + for i := len(files) - 1; i >= 0; i-- { + if _, ok := toDelete[files[i].path]; ok { + continue + } + if kept >= d.config.MaxBackups { + toDelete[files[i].path] = struct{}{} + continue + } + kept++ + } + } + + for path := range toDelete { + if err := os.Remove(path); err != nil { + slog.Warn("failed to remove old dump file", "path", path, "error", err) + } + } } diff --git a/internal/ent/apikey.go b/internal/ent/apikey.go index 32b9fdc10..58c348279 100644 --- a/internal/ent/apikey.go +++ b/internal/ent/apikey.go @@ -33,6 +33,8 @@ type APIKey struct { ProjectID int `json:"project_id,omitempty"` // Key holds the value of the "key" field. Key string `json:"key,omitempty"` + // SHA-256 hash of the API key for secure verification + KeyHash string `json:"key_hash,omitempty"` // Name holds the value of the "name" field. Name string `json:"name,omitempty"` // API Key type: user, service_account, or noauth @@ -106,7 +108,7 @@ func (*APIKey) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case apikey.FieldID, apikey.FieldDeletedAt, apikey.FieldUserID, apikey.FieldProjectID: values[i] = new(sql.NullInt64) - case apikey.FieldKey, apikey.FieldName, apikey.FieldType, apikey.FieldStatus: + case apikey.FieldKey, apikey.FieldKeyHash, apikey.FieldName, apikey.FieldType, apikey.FieldStatus: values[i] = new(sql.NullString) case apikey.FieldCreatedAt, apikey.FieldUpdatedAt: values[i] = new(sql.NullTime) @@ -167,6 +169,12 @@ func (_m *APIKey) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Key = value.String } + case apikey.FieldKeyHash: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field key_hash", values[i]) + } else if value.Valid { + _m.KeyHash = value.String + } case apikey.FieldName: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field name", values[i]) @@ -270,6 +278,9 @@ func (_m *APIKey) String() string { builder.WriteString("key=") builder.WriteString(_m.Key) builder.WriteString(", ") + builder.WriteString("key_hash=") + builder.WriteString(_m.KeyHash) + builder.WriteString(", ") builder.WriteString("name=") builder.WriteString(_m.Name) builder.WriteString(", ") diff --git a/internal/ent/apikey/apikey.go b/internal/ent/apikey/apikey.go index a7b10c762..a299a7b85 100644 --- a/internal/ent/apikey/apikey.go +++ b/internal/ent/apikey/apikey.go @@ -31,6 +31,8 @@ const ( FieldProjectID = "project_id" // FieldKey holds the string denoting the key field in the database. FieldKey = "key" + // FieldKeyHash holds the string denoting the key_hash field in the database. + FieldKeyHash = "key_hash" // FieldName holds the string denoting the name field in the database. FieldName = "name" // FieldType holds the string denoting the type field in the database. @@ -81,6 +83,7 @@ var Columns = []string{ FieldUserID, FieldProjectID, FieldKey, + FieldKeyHash, FieldName, FieldType, FieldStatus, @@ -215,6 +218,11 @@ func ByKey(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldKey, opts...).ToFunc() } +// ByKeyHash orders the results by the key_hash field. +func ByKeyHash(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldKeyHash, opts...).ToFunc() +} + // ByName orders the results by the name field. func ByName(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldName, opts...).ToFunc() diff --git a/internal/ent/apikey/where.go b/internal/ent/apikey/where.go index 590a1bbde..fdcde9a84 100644 --- a/internal/ent/apikey/where.go +++ b/internal/ent/apikey/where.go @@ -85,6 +85,11 @@ func Key(v string) predicate.APIKey { return predicate.APIKey(sql.FieldEQ(FieldKey, v)) } +// KeyHash applies equality check predicate on the "key_hash" field. It's identical to KeyHashEQ. +func KeyHash(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldKeyHash, v)) +} + // Name applies equality check predicate on the "name" field. It's identical to NameEQ. func Name(v string) predicate.APIKey { return predicate.APIKey(sql.FieldEQ(FieldName, v)) @@ -325,6 +330,81 @@ func KeyContainsFold(v string) predicate.APIKey { return predicate.APIKey(sql.FieldContainsFold(FieldKey, v)) } +// KeyHashEQ applies the EQ predicate on the "key_hash" field. +func KeyHashEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldKeyHash, v)) +} + +// KeyHashNEQ applies the NEQ predicate on the "key_hash" field. +func KeyHashNEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldKeyHash, v)) +} + +// KeyHashIn applies the In predicate on the "key_hash" field. +func KeyHashIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldKeyHash, vs...)) +} + +// KeyHashNotIn applies the NotIn predicate on the "key_hash" field. +func KeyHashNotIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldKeyHash, vs...)) +} + +// KeyHashGT applies the GT predicate on the "key_hash" field. +func KeyHashGT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldKeyHash, v)) +} + +// KeyHashGTE applies the GTE predicate on the "key_hash" field. +func KeyHashGTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldKeyHash, v)) +} + +// KeyHashLT applies the LT predicate on the "key_hash" field. +func KeyHashLT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldKeyHash, v)) +} + +// KeyHashLTE applies the LTE predicate on the "key_hash" field. +func KeyHashLTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldKeyHash, v)) +} + +// KeyHashContains applies the Contains predicate on the "key_hash" field. +func KeyHashContains(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContains(FieldKeyHash, v)) +} + +// KeyHashHasPrefix applies the HasPrefix predicate on the "key_hash" field. +func KeyHashHasPrefix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasPrefix(FieldKeyHash, v)) +} + +// KeyHashHasSuffix applies the HasSuffix predicate on the "key_hash" field. +func KeyHashHasSuffix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasSuffix(FieldKeyHash, v)) +} + +// KeyHashIsNil applies the IsNil predicate on the "key_hash" field. +func KeyHashIsNil() predicate.APIKey { + return predicate.APIKey(sql.FieldIsNull(FieldKeyHash)) +} + +// KeyHashNotNil applies the NotNil predicate on the "key_hash" field. +func KeyHashNotNil() predicate.APIKey { + return predicate.APIKey(sql.FieldNotNull(FieldKeyHash)) +} + +// KeyHashEqualFold applies the EqualFold predicate on the "key_hash" field. +func KeyHashEqualFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEqualFold(FieldKeyHash, v)) +} + +// KeyHashContainsFold applies the ContainsFold predicate on the "key_hash" field. +func KeyHashContainsFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContainsFold(FieldKeyHash, v)) +} + // NameEQ applies the EQ predicate on the "name" field. func NameEQ(v string) predicate.APIKey { return predicate.APIKey(sql.FieldEQ(FieldName, v)) diff --git a/internal/ent/apikey_create.go b/internal/ent/apikey_create.go index b947af6a4..591fcf669 100644 --- a/internal/ent/apikey_create.go +++ b/internal/ent/apikey_create.go @@ -102,6 +102,20 @@ func (_c *APIKeyCreate) SetKey(v string) *APIKeyCreate { return _c } +// SetKeyHash sets the "key_hash" field. +func (_c *APIKeyCreate) SetKeyHash(v string) *APIKeyCreate { + _c.mutation.SetKeyHash(v) + return _c +} + +// SetNillableKeyHash sets the "key_hash" field if the given value is not nil. +func (_c *APIKeyCreate) SetNillableKeyHash(v *string) *APIKeyCreate { + if v != nil { + _c.SetKeyHash(*v) + } + return _c +} + // SetName sets the "name" field. func (_c *APIKeyCreate) SetName(v string) *APIKeyCreate { _c.mutation.SetName(v) @@ -327,6 +341,10 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) { _spec.SetField(apikey.FieldKey, field.TypeString, value) _node.Key = value } + if value, ok := _c.mutation.KeyHash(); ok { + _spec.SetField(apikey.FieldKeyHash, field.TypeString, value) + _node.KeyHash = value + } if value, ok := _c.mutation.Name(); ok { _spec.SetField(apikey.FieldName, field.TypeString, value) _node.Name = value @@ -574,6 +592,9 @@ func (u *APIKeyUpsertOne) UpdateNewValues() *APIKeyUpsertOne { if _, exists := u.create.mutation.Key(); exists { s.SetIgnore(apikey.FieldKey) } + if _, exists := u.create.mutation.KeyHash(); exists { + s.SetIgnore(apikey.FieldKeyHash) + } })) return u } @@ -912,6 +933,9 @@ func (u *APIKeyUpsertBulk) UpdateNewValues() *APIKeyUpsertBulk { if _, exists := b.mutation.Key(); exists { s.SetIgnore(apikey.FieldKey) } + if _, exists := b.mutation.KeyHash(); exists { + s.SetIgnore(apikey.FieldKeyHash) + } } })) return u diff --git a/internal/ent/apikey_update.go b/internal/ent/apikey_update.go index f2e6cfbd0..9ae919b6c 100644 --- a/internal/ent/apikey_update.go +++ b/internal/ent/apikey_update.go @@ -259,6 +259,9 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedDeletedAt(); ok { _spec.AddField(apikey.FieldDeletedAt, field.TypeInt, value) } + if _u.mutation.KeyHashCleared() { + _spec.ClearField(apikey.FieldKeyHash, field.TypeString) + } if value, ok := _u.mutation.Name(); ok { _spec.SetField(apikey.FieldName, field.TypeString, value) } @@ -609,6 +612,9 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro if value, ok := _u.mutation.AddedDeletedAt(); ok { _spec.AddField(apikey.FieldDeletedAt, field.TypeInt, value) } + if _u.mutation.KeyHashCleared() { + _spec.ClearField(apikey.FieldKeyHash, field.TypeString) + } if value, ok := _u.mutation.Name(); ok { _spec.SetField(apikey.FieldName, field.TypeString, value) } diff --git a/internal/ent/ent.graphql b/internal/ent/ent.graphql new file mode 100644 index 000000000..0f5f58fbc --- /dev/null +++ b/internal/ent/ent.graphql @@ -0,0 +1,6815 @@ +directive @goField(forceResolver: Boolean, name: String, omittable: Boolean) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +directive @goModel(model: String, models: [String!], forceGenerate: Boolean) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION +type APIKey implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + The creator of the API key + """ + userID: ID + """ + Project ID, default to 1 for backward compatibility + """ + projectID: ID! + key: String! + """ + SHA-256 hash of the API key for secure verification + """ + keyHash: String + name: String! + """ + API Key type: user, service_account, or noauth + """ + type: APIKeyType! + status: APIKeyStatus! + """ + API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes. + """ + scopes: [String!] + profiles: APIKeyProfiles + user: User @goField(forceResolver: true) + project: Project! + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! +} +""" +A connection to a list of items. +""" +type APIKeyConnection { + """ + A list of edges. + """ + edges: [APIKeyEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type APIKeyEdge { + """ + The item at the end of the edge. + """ + node: APIKey + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for APIKey connections +""" +input APIKeyOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order APIKeys. + """ + field: APIKeyOrderField! +} +""" +Properties by which APIKey connections can be ordered. +""" +enum APIKeyOrderField { + CREATED_AT + UPDATED_AT +} +""" +APIKeyStatus is enum for the field status +""" +enum APIKeyStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/apikey.Status") { + enabled + disabled + archived +} +""" +APIKeyType is enum for the field type +""" +enum APIKeyType @goModel(model: "github.com/looplj/axonhub/internal/ent/apikey.Type") { + user + service_account + noauth +} +""" +APIKeyWhereInput is used for filtering APIKey objects. +Input was generated by ent. +""" +input APIKeyWhereInput { + not: APIKeyWhereInput + and: [APIKeyWhereInput!] + or: [APIKeyWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + user_id field predicates + """ + userID: ID + userIDNEQ: ID + userIDIn: [ID!] + userIDNotIn: [ID!] + userIDIsNil: Boolean + userIDNotNil: Boolean + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + """ + key field predicates + """ + key: String + keyNEQ: String + keyIn: [String!] + keyNotIn: [String!] + keyGT: String + keyGTE: String + keyLT: String + keyLTE: String + keyContains: String + keyHasPrefix: String + keyHasSuffix: String + keyEqualFold: String + keyContainsFold: String + """ + key_hash field predicates + """ + keyHash: String + keyHashNEQ: String + keyHashIn: [String!] + keyHashNotIn: [String!] + keyHashGT: String + keyHashGTE: String + keyHashLT: String + keyHashLTE: String + keyHashContains: String + keyHashHasPrefix: String + keyHashHasSuffix: String + keyHashIsNil: Boolean + keyHashNotNil: Boolean + keyHashEqualFold: String + keyHashContainsFold: String + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + type field predicates + """ + type: APIKeyType + typeNEQ: APIKeyType + typeIn: [APIKeyType!] + typeNotIn: [APIKeyType!] + """ + status field predicates + """ + status: APIKeyStatus + statusNEQ: APIKeyStatus + statusIn: [APIKeyStatus!] + statusNotIn: [APIKeyStatus!] + """ + user edge predicates + """ + hasUser: Boolean + hasUserWith: [UserWhereInput!] + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + requests edge predicates + """ + hasRequests: Boolean + hasRequestsWith: [RequestWhereInput!] +} +type Channel implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + type: ChannelType! + baseURL: String + name: String! + status: ChannelStatus! + supportedModels: [String!]! + manualModels: [String!] + autoSyncSupportedModels: Boolean! + """ + Regex pattern to filter models during auto-sync. Empty string means no filtering. + """ + autoSyncModelPattern: String + tags: [String!] + defaultTestModel: String! + policies: ChannelPolicies @goField(forceResolver: true) + settings: ChannelSettings + """ + Ordering weight for display sorting + """ + orderingWeight: Int! + errorMessage: String + """ + User-defined remark or note for the channel + """ + remark: String + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! + executions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for RequestExecutions returned from the connection. + """ + orderBy: RequestExecutionOrder + + """ + Filtering options for RequestExecutions returned from the connection. + """ + where: RequestExecutionWhereInput + ): RequestExecutionConnection! + usageLogs( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for UsageLogs returned from the connection. + """ + orderBy: UsageLogOrder + + """ + Filtering options for UsageLogs returned from the connection. + """ + where: UsageLogWhereInput + ): UsageLogConnection! + channelProbes: [ChannelProbe!] + channelModelPrices: [ChannelModelPrice!] + providerQuotaStatus: ProviderQuotaStatus @goField(forceResolver: true) +} +""" +A connection to a list of items. +""" +type ChannelConnection { + """ + A list of edges. + """ + edges: [ChannelEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ChannelEdge { + """ + The item at the end of the edge. + """ + node: Channel + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +type ChannelModelPrice implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + channelID: ID! + modelID: String! + """ + The model price, if changed, it will genearte a new reference id. + """ + price: ModelPrice! + """ + The bill should reference this id. + """ + referenceID: String! + channel: Channel! + versions: [ChannelModelPriceVersion!] +} +""" +A connection to a list of items. +""" +type ChannelModelPriceConnection { + """ + A list of edges. + """ + edges: [ChannelModelPriceEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ChannelModelPriceEdge { + """ + The item at the end of the edge. + """ + node: ChannelModelPrice + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for ChannelModelPrice connections +""" +input ChannelModelPriceOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order ChannelModelPrices. + """ + field: ChannelModelPriceOrderField! +} +""" +Properties by which ChannelModelPrice connections can be ordered. +""" +enum ChannelModelPriceOrderField { + CREATED_AT + UPDATED_AT +} +type ChannelModelPriceVersion implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + channelID: Int! + modelID: String! + channelModelPriceID: ID! + """ + The model price, if changed, it will genearte a new reference id. + """ + price: ModelPrice! + status: ChannelModelPriceVersionStatus! + """ + The effective start time of the model price. + """ + effectiveStartAt: Time! + """ + The effective end time of the model price, null means it is effective until the next version. + """ + effectiveEndAt: Time + """ + The bill should reference this id. + """ + referenceID: String! + channelModelPrice: ChannelModelPrice! +} +""" +A connection to a list of items. +""" +type ChannelModelPriceVersionConnection { + """ + A list of edges. + """ + edges: [ChannelModelPriceVersionEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ChannelModelPriceVersionEdge { + """ + The item at the end of the edge. + """ + node: ChannelModelPriceVersion + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for ChannelModelPriceVersion connections +""" +input ChannelModelPriceVersionOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order ChannelModelPriceVersions. + """ + field: ChannelModelPriceVersionOrderField! +} +""" +Properties by which ChannelModelPriceVersion connections can be ordered. +""" +enum ChannelModelPriceVersionOrderField { + CREATED_AT + UPDATED_AT +} +""" +ChannelModelPriceVersionStatus is enum for the field status +""" +enum ChannelModelPriceVersionStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/channelmodelpriceversion.Status") { + active + archived +} +""" +ChannelModelPriceVersionWhereInput is used for filtering ChannelModelPriceVersion objects. +Input was generated by ent. +""" +input ChannelModelPriceVersionWhereInput { + not: ChannelModelPriceVersionWhereInput + and: [ChannelModelPriceVersionWhereInput!] + or: [ChannelModelPriceVersionWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + channel_id field predicates + """ + channelID: Int + channelIDNEQ: Int + channelIDIn: [Int!] + channelIDNotIn: [Int!] + channelIDGT: Int + channelIDGTE: Int + channelIDLT: Int + channelIDLTE: Int + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + channel_model_price_id field predicates + """ + channelModelPriceID: ID + channelModelPriceIDNEQ: ID + channelModelPriceIDIn: [ID!] + channelModelPriceIDNotIn: [ID!] + """ + status field predicates + """ + status: ChannelModelPriceVersionStatus + statusNEQ: ChannelModelPriceVersionStatus + statusIn: [ChannelModelPriceVersionStatus!] + statusNotIn: [ChannelModelPriceVersionStatus!] + """ + effective_start_at field predicates + """ + effectiveStartAt: Time + effectiveStartAtNEQ: Time + effectiveStartAtIn: [Time!] + effectiveStartAtNotIn: [Time!] + effectiveStartAtGT: Time + effectiveStartAtGTE: Time + effectiveStartAtLT: Time + effectiveStartAtLTE: Time + """ + effective_end_at field predicates + """ + effectiveEndAt: Time + effectiveEndAtNEQ: Time + effectiveEndAtIn: [Time!] + effectiveEndAtNotIn: [Time!] + effectiveEndAtGT: Time + effectiveEndAtGTE: Time + effectiveEndAtLT: Time + effectiveEndAtLTE: Time + effectiveEndAtIsNil: Boolean + effectiveEndAtNotNil: Boolean + """ + reference_id field predicates + """ + referenceID: String + referenceIDNEQ: String + referenceIDIn: [String!] + referenceIDNotIn: [String!] + referenceIDGT: String + referenceIDGTE: String + referenceIDLT: String + referenceIDLTE: String + referenceIDContains: String + referenceIDHasPrefix: String + referenceIDHasSuffix: String + referenceIDEqualFold: String + referenceIDContainsFold: String + """ + channel_model_price edge predicates + """ + hasChannelModelPrice: Boolean + hasChannelModelPriceWith: [ChannelModelPriceWhereInput!] +} +""" +ChannelModelPriceWhereInput is used for filtering ChannelModelPrice objects. +Input was generated by ent. +""" +input ChannelModelPriceWhereInput { + not: ChannelModelPriceWhereInput + and: [ChannelModelPriceWhereInput!] + or: [ChannelModelPriceWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + reference_id field predicates + """ + referenceID: String + referenceIDNEQ: String + referenceIDIn: [String!] + referenceIDNotIn: [String!] + referenceIDGT: String + referenceIDGTE: String + referenceIDLT: String + referenceIDLTE: String + referenceIDContains: String + referenceIDHasPrefix: String + referenceIDHasSuffix: String + referenceIDEqualFold: String + referenceIDContainsFold: String + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] + """ + versions edge predicates + """ + hasVersions: Boolean + hasVersionsWith: [ChannelModelPriceVersionWhereInput!] +} +""" +Ordering options for Channel connections +""" +input ChannelOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Channels. + """ + field: ChannelOrderField! +} +""" +Properties by which Channel connections can be ordered. +""" +enum ChannelOrderField { + CREATED_AT + UPDATED_AT + TYPE + NAME + STATUS + ORDERING_WEIGHT +} +type ChannelOverrideTemplate implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Owner of this template + """ + userID: ID + """ + Template name, unique per user + """ + name: String! + """ + Template description + """ + description: String + """ + Override request body parameters as JSON string + """ + overrideParameters: String! @deprecated(reason: "Use body_override_operations instead") + """ + Override request headers + """ + overrideHeaders: [HeaderEntry!]! @deprecated + """ + Override request headers + """ + headerOverrideOperations: [OverrideOperation!] @goField(forceResolver: true) + """ + Override request body parameters + """ + bodyOverrideOperations: [OverrideOperation!] @goField(forceResolver: true) + user: User @goField(forceResolver: true) +} +""" +A connection to a list of items. +""" +type ChannelOverrideTemplateConnection { + """ + A list of edges. + """ + edges: [ChannelOverrideTemplateEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ChannelOverrideTemplateEdge { + """ + The item at the end of the edge. + """ + node: ChannelOverrideTemplate + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for ChannelOverrideTemplate connections +""" +input ChannelOverrideTemplateOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order ChannelOverrideTemplates. + """ + field: ChannelOverrideTemplateOrderField! +} +""" +Properties by which ChannelOverrideTemplate connections can be ordered. +""" +enum ChannelOverrideTemplateOrderField { + CREATED_AT + UPDATED_AT +} +""" +ChannelOverrideTemplateWhereInput is used for filtering ChannelOverrideTemplate objects. +Input was generated by ent. +""" +input ChannelOverrideTemplateWhereInput { + not: ChannelOverrideTemplateWhereInput + and: [ChannelOverrideTemplateWhereInput!] + or: [ChannelOverrideTemplateWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + user_id field predicates + """ + userID: ID + userIDNEQ: ID + userIDIn: [ID!] + userIDNotIn: [ID!] + userIDIsNil: Boolean + userIDNotNil: Boolean + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + description field predicates + """ + description: String + descriptionNEQ: String + descriptionIn: [String!] + descriptionNotIn: [String!] + descriptionGT: String + descriptionGTE: String + descriptionLT: String + descriptionLTE: String + descriptionContains: String + descriptionHasPrefix: String + descriptionHasSuffix: String + descriptionIsNil: Boolean + descriptionNotNil: Boolean + descriptionEqualFold: String + descriptionContainsFold: String + """ + override_parameters field predicates + """ + overrideParameters: String + overrideParametersNEQ: String + overrideParametersIn: [String!] + overrideParametersNotIn: [String!] + overrideParametersGT: String + overrideParametersGTE: String + overrideParametersLT: String + overrideParametersLTE: String + overrideParametersContains: String + overrideParametersHasPrefix: String + overrideParametersHasSuffix: String + overrideParametersEqualFold: String + overrideParametersContainsFold: String + """ + user edge predicates + """ + hasUser: Boolean + hasUserWith: [UserWhereInput!] +} +type ChannelProbe implements Node { + id: ID! + channelID: ID! + totalRequestCount: Int! + successRequestCount: Int! + avgTokensPerSecond: Float + avgTimeToFirstTokenMs: Float + timestamp: Int! + channel: Channel! +} +""" +ChannelProbeWhereInput is used for filtering ChannelProbe objects. +Input was generated by ent. +""" +input ChannelProbeWhereInput { + not: ChannelProbeWhereInput + and: [ChannelProbeWhereInput!] + or: [ChannelProbeWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + """ + total_request_count field predicates + """ + totalRequestCount: Int + totalRequestCountNEQ: Int + totalRequestCountIn: [Int!] + totalRequestCountNotIn: [Int!] + totalRequestCountGT: Int + totalRequestCountGTE: Int + totalRequestCountLT: Int + totalRequestCountLTE: Int + """ + success_request_count field predicates + """ + successRequestCount: Int + successRequestCountNEQ: Int + successRequestCountIn: [Int!] + successRequestCountNotIn: [Int!] + successRequestCountGT: Int + successRequestCountGTE: Int + successRequestCountLT: Int + successRequestCountLTE: Int + """ + avg_tokens_per_second field predicates + """ + avgTokensPerSecond: Float + avgTokensPerSecondNEQ: Float + avgTokensPerSecondIn: [Float!] + avgTokensPerSecondNotIn: [Float!] + avgTokensPerSecondGT: Float + avgTokensPerSecondGTE: Float + avgTokensPerSecondLT: Float + avgTokensPerSecondLTE: Float + avgTokensPerSecondIsNil: Boolean + avgTokensPerSecondNotNil: Boolean + """ + avg_time_to_first_token_ms field predicates + """ + avgTimeToFirstTokenMs: Float + avgTimeToFirstTokenMsNEQ: Float + avgTimeToFirstTokenMsIn: [Float!] + avgTimeToFirstTokenMsNotIn: [Float!] + avgTimeToFirstTokenMsGT: Float + avgTimeToFirstTokenMsGTE: Float + avgTimeToFirstTokenMsLT: Float + avgTimeToFirstTokenMsLTE: Float + avgTimeToFirstTokenMsIsNil: Boolean + avgTimeToFirstTokenMsNotNil: Boolean + """ + timestamp field predicates + """ + timestamp: Int + timestampNEQ: Int + timestampIn: [Int!] + timestampNotIn: [Int!] + timestampGT: Int + timestampGTE: Int + timestampLT: Int + timestampLTE: Int + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] +} +""" +ChannelStatus is enum for the field status +""" +enum ChannelStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/channel.Status") { + enabled + disabled + archived +} +""" +ChannelType is enum for the field type +""" +enum ChannelType @goModel(model: "github.com/looplj/axonhub/internal/ent/channel.Type") { + openai + openai_responses + codex + vercel + anthropic + anthropic_aws + anthropic_gcp + gemini_openai + gemini + gemini_vertex + deepseek + deepseek_anthropic + deepinfra + fireworks + doubao + doubao_anthropic + moonshot + moonshot_anthropic + zhipu + zai + zhipu_anthropic + zai_anthropic + anthropic_fake + openai_fake + openrouter + xiaomi + xai + ppio + siliconflow + volcengine + longcat + longcat_anthropic + minimax + minimax_anthropic + aihubmix + burncloud + modelscope + bailian + bailian_anthropic + moonshot_coding + jina + github + github_copilot + claudecode + cerebras + antigravity + nanogpt + nanogpt_responses + ollama +} +""" +ChannelWhereInput is used for filtering Channel objects. +Input was generated by ent. +""" +input ChannelWhereInput { + not: ChannelWhereInput + and: [ChannelWhereInput!] + or: [ChannelWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + type field predicates + """ + type: ChannelType + typeNEQ: ChannelType + typeIn: [ChannelType!] + typeNotIn: [ChannelType!] + """ + base_url field predicates + """ + baseURL: String + baseURLNEQ: String + baseURLIn: [String!] + baseURLNotIn: [String!] + baseURLGT: String + baseURLGTE: String + baseURLLT: String + baseURLLTE: String + baseURLContains: String + baseURLHasPrefix: String + baseURLHasSuffix: String + baseURLIsNil: Boolean + baseURLNotNil: Boolean + baseURLEqualFold: String + baseURLContainsFold: String + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + status field predicates + """ + status: ChannelStatus + statusNEQ: ChannelStatus + statusIn: [ChannelStatus!] + statusNotIn: [ChannelStatus!] + """ + auto_sync_supported_models field predicates + """ + autoSyncSupportedModels: Boolean + autoSyncSupportedModelsNEQ: Boolean + """ + auto_sync_model_pattern field predicates + """ + autoSyncModelPattern: String + autoSyncModelPatternNEQ: String + autoSyncModelPatternIn: [String!] + autoSyncModelPatternNotIn: [String!] + autoSyncModelPatternGT: String + autoSyncModelPatternGTE: String + autoSyncModelPatternLT: String + autoSyncModelPatternLTE: String + autoSyncModelPatternContains: String + autoSyncModelPatternHasPrefix: String + autoSyncModelPatternHasSuffix: String + autoSyncModelPatternIsNil: Boolean + autoSyncModelPatternNotNil: Boolean + autoSyncModelPatternEqualFold: String + autoSyncModelPatternContainsFold: String + """ + default_test_model field predicates + """ + defaultTestModel: String + defaultTestModelNEQ: String + defaultTestModelIn: [String!] + defaultTestModelNotIn: [String!] + defaultTestModelGT: String + defaultTestModelGTE: String + defaultTestModelLT: String + defaultTestModelLTE: String + defaultTestModelContains: String + defaultTestModelHasPrefix: String + defaultTestModelHasSuffix: String + defaultTestModelEqualFold: String + defaultTestModelContainsFold: String + """ + ordering_weight field predicates + """ + orderingWeight: Int + orderingWeightNEQ: Int + orderingWeightIn: [Int!] + orderingWeightNotIn: [Int!] + orderingWeightGT: Int + orderingWeightGTE: Int + orderingWeightLT: Int + orderingWeightLTE: Int + """ + error_message field predicates + """ + errorMessage: String + errorMessageNEQ: String + errorMessageIn: [String!] + errorMessageNotIn: [String!] + errorMessageGT: String + errorMessageGTE: String + errorMessageLT: String + errorMessageLTE: String + errorMessageContains: String + errorMessageHasPrefix: String + errorMessageHasSuffix: String + errorMessageIsNil: Boolean + errorMessageNotNil: Boolean + errorMessageEqualFold: String + errorMessageContainsFold: String + """ + remark field predicates + """ + remark: String + remarkNEQ: String + remarkIn: [String!] + remarkNotIn: [String!] + remarkGT: String + remarkGTE: String + remarkLT: String + remarkLTE: String + remarkContains: String + remarkHasPrefix: String + remarkHasSuffix: String + remarkIsNil: Boolean + remarkNotNil: Boolean + remarkEqualFold: String + remarkContainsFold: String + """ + requests edge predicates + """ + hasRequests: Boolean + hasRequestsWith: [RequestWhereInput!] + """ + executions edge predicates + """ + hasExecutions: Boolean + hasExecutionsWith: [RequestExecutionWhereInput!] + """ + usage_logs edge predicates + """ + hasUsageLogs: Boolean + hasUsageLogsWith: [UsageLogWhereInput!] + """ + channel_probes edge predicates + """ + hasChannelProbes: Boolean + hasChannelProbesWith: [ChannelProbeWhereInput!] + """ + channel_model_prices edge predicates + """ + hasChannelModelPrices: Boolean + hasChannelModelPricesWith: [ChannelModelPriceWhereInput!] + """ + provider_quota_status edge predicates + """ + hasProviderQuotaStatus: Boolean + hasProviderQuotaStatusWith: [ProviderQuotaStatusWhereInput!] +} +""" +CreateAPIKeyInput is used for create APIKey object. +Input was generated by ent. +""" +input CreateAPIKeyInput { + name: String! + """ + API Key type: user, service_account, or noauth + """ + type: APIKeyType + """ + API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes. + """ + scopes: [String!] + projectID: ID! +} +""" +CreateChannelInput is used for create Channel object. +Input was generated by ent. +""" +input CreateChannelInput { + type: ChannelType! + baseURL: String + name: String! + credentials: ChannelCredentialsInput! + supportedModels: [String!]! + manualModels: [String!] + autoSyncSupportedModels: Boolean + """ + Regex pattern to filter models during auto-sync. Empty string means no filtering. + """ + autoSyncModelPattern: String + tags: [String!] + defaultTestModel: String! + policies: ChannelPoliciesInput + settings: ChannelSettingsInput + """ + Ordering weight for display sorting + """ + orderingWeight: Int + """ + User-defined remark or note for the channel + """ + remark: String +} +""" +CreateChannelOverrideTemplateInput is used for create ChannelOverrideTemplate object. +Input was generated by ent. +""" +input CreateChannelOverrideTemplateInput { + """ + Template name, unique per user + """ + name: String! + """ + Template description + """ + description: String + """ + Override request headers + """ + headerOverrideOperations: [OverrideOperationInput!] + """ + Override request body parameters + """ + bodyOverrideOperations: [OverrideOperationInput!] +} +""" +CreateDataStorageInput is used for create DataStorage object. +Input was generated by ent. +""" +input CreateDataStorageInput { + """ + data source name + """ + name: String! + """ + data source description + """ + description: String! + """ + data source type + """ + type: DataStorageType! + """ + data source setting + """ + settings: DataStorageSettingsInput! + """ + data source status + """ + status: DataStorageStatus +} +""" +CreateModelInput is used for create Model object. +Input was generated by ent. +""" +input CreateModelInput { + """ + developer of the model, eg. deeepseek + """ + developer: String! + """ + model id, eg. deeepseek-chat + """ + modelID: String! + """ + model type + """ + type: ModelType + """ + model name, eg. DeepSeek Chat + """ + name: String! + """ + icon of the model from the lobe-icons, eg. DeepSeek + """ + icon: String! + """ + model group, eg. deepseek + """ + group: String! + modelCard: ModelCardInput! + settings: ModelSettingsInput! + """ + User-defined remark or note for the Model + """ + remark: String +} +""" +CreateProjectInput is used for create Project object. +Input was generated by ent. +""" +input CreateProjectInput { + """ + project name + """ + name: String! + """ + project description + """ + description: String + """ + project status + """ + status: ProjectStatus + userIDs: [ID!] +} +""" +CreatePromptInput is used for create Prompt object. +Input was generated by ent. +""" +input CreatePromptInput { + """ + prompt name + """ + name: String! + """ + prompt description + """ + description: String + """ + prompt role + """ + role: String! + """ + prompt content + """ + content: String! + status: PromptStatus + """ + prompt insertion order, smaller values are inserted first + """ + order: Int + """ + prompt settings in JSON format + """ + settings: PromptSettingsInput! + projectIDs: [ID!] +} +""" +CreatePromptProtectionRuleInput is used for create PromptProtectionRule object. +Input was generated by ent. +""" +input CreatePromptProtectionRuleInput { + """ + Rule name + """ + name: String! + """ + Rule description + """ + description: String + """ + Regex pattern to match prompt content + """ + pattern: String! + """ + Prompt protection rule settings + """ + settings: PromptProtectionSettingsInput! +} +""" +CreateRequestInput is used for create Request object. +Input was generated by ent. +""" +input CreateRequestInput { + source: RequestSource + modelID: String! + format: String + """ + Request headers + """ + requestHeaders: JSONRawMessageInput + requestBody: JSONRawMessageInput! + responseBody: JSONRawMessageInput + responseChunks: [JSONRawMessageInput!] + externalID: String + status: RequestStatus! + stream: Boolean + clientIP: String + metricsLatencyMs: Int + metricsFirstTokenLatencyMs: Int + """ + Reasoning/thinking duration in milliseconds + """ + metricsReasoningDurationMs: Int + """ + whether the generated content has been saved to external storage + """ + contentSaved: Boolean + """ + data storage id used to save the content file + """ + contentStorageID: Int + """ + storage key/path of the saved content file + """ + contentStorageKey: String + """ + when the content file was saved + """ + contentSavedAt: Time + apiKeyID: ID + projectID: ID! + traceID: ID + dataStorageID: ID + channelID: ID +} +""" +CreateRoleInput is used for create Role object. +Input was generated by ent. +""" +input CreateRoleInput { + name: String! + """ + Role level: system or project + """ + level: RoleLevel + """ + Available scopes for this role: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + userIDs: [ID!] + projectID: ID +} +""" +CreateSystemInput is used for create System object. +Input was generated by ent. +""" +input CreateSystemInput { + key: String! + value: String! +} +""" +CreateThreadInput is used for create Thread object. +Input was generated by ent. +""" +input CreateThreadInput { + """ + Unique thread identifier for this thread + """ + threadID: String! + projectID: ID! +} +""" +CreateTraceInput is used for create Trace object. +Input was generated by ent. +""" +input CreateTraceInput { + """ + Unique trace identifier + """ + traceID: String! + projectID: ID! + threadID: ID +} +""" +CreateUsageLogInput is used for create UsageLog object. +Input was generated by ent. +""" +input CreateUsageLogInput { + apiKeyID: Int + """ + Model identifier used for the request + """ + modelID: String! + """ + Number of tokens in the prompt + """ + promptTokens: Int + """ + Number of tokens in the completion + """ + completionTokens: Int + """ + Total number of tokens used + """ + totalTokens: Int + """ + Number of audio tokens in the prompt + """ + promptAudioTokens: Int + """ + Number of cached tokens in the prompt + """ + promptCachedTokens: Int + """ + Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h + """ + promptWriteCachedTokens: Int + """ + Number of token write cache with 5m ttl + """ + promptWriteCachedTokens5m: Int + """ + Number of token write cache with 1h ttl + """ + promptWriteCachedTokens1h: Int + """ + Number of audio tokens in the completion + """ + completionAudioTokens: Int + """ + Number of reasoning tokens in the completion + """ + completionReasoningTokens: Int + """ + Number of accepted prediction tokens + """ + completionAcceptedPredictionTokens: Int + """ + Number of rejected prediction tokens + """ + completionRejectedPredictionTokens: Int + """ + Source of the request + """ + source: UsageLogSource + """ + Request format used + """ + format: String + """ + Total cost calculated based on channel model price + """ + totalCost: Float + """ + Detailed cost breakdown items in JSON + """ + costItems: [CostItemInput!] + """ + Reference ID to the channel model price version used for cost calculation + """ + costPriceReferenceID: String + requestID: ID! + projectID: ID! + channelID: ID +} +""" +CreateUserInput is used for create User object. +Input was generated by ent. +""" +input CreateUserInput { + email: String! + status: UserStatus + """ + 用户偏好语言 + """ + preferLanguage: String + password: String! + firstName: String + lastName: String + """ + 用户头像URL + """ + avatar: String + isOwner: Boolean + """ + User scopes in system level: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + projectIDs: [ID!] + roleIDs: [ID!] +} +""" +Define a Relay Cursor type: +https://relay.dev/graphql/connections.htm#sec-Cursor +""" +scalar Cursor +type DataStorage implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + data source name + """ + name: String! + """ + data source description + """ + description: String! + """ + data source is primary, only the system database is the primary, it can not be archived. + """ + primary: Boolean! + """ + data source type + """ + type: DataStorageType! + """ + data source setting + """ + settings: DataStorageSettings! + """ + data source status + """ + status: DataStorageStatus! + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! + executions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for RequestExecutions returned from the connection. + """ + orderBy: RequestExecutionOrder + + """ + Filtering options for RequestExecutions returned from the connection. + """ + where: RequestExecutionWhereInput + ): RequestExecutionConnection! +} +""" +A connection to a list of items. +""" +type DataStorageConnection { + """ + A list of edges. + """ + edges: [DataStorageEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type DataStorageEdge { + """ + The item at the end of the edge. + """ + node: DataStorage + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for DataStorage connections +""" +input DataStorageOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order DataStorages. + """ + field: DataStorageOrderField! +} +""" +Properties by which DataStorage connections can be ordered. +""" +enum DataStorageOrderField { + CREATED_AT + UPDATED_AT +} +""" +DataStorageStatus is enum for the field status +""" +enum DataStorageStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/datastorage.Status") { + active + archived +} +""" +DataStorageType is enum for the field type +""" +enum DataStorageType @goModel(model: "github.com/looplj/axonhub/internal/ent/datastorage.Type") { + database + fs + s3 + gcs + webdav +} +""" +DataStorageWhereInput is used for filtering DataStorage objects. +Input was generated by ent. +""" +input DataStorageWhereInput { + not: DataStorageWhereInput + and: [DataStorageWhereInput!] + or: [DataStorageWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + description field predicates + """ + description: String + descriptionNEQ: String + descriptionIn: [String!] + descriptionNotIn: [String!] + descriptionGT: String + descriptionGTE: String + descriptionLT: String + descriptionLTE: String + descriptionContains: String + descriptionHasPrefix: String + descriptionHasSuffix: String + descriptionEqualFold: String + descriptionContainsFold: String + """ + primary field predicates + """ + primary: Boolean + primaryNEQ: Boolean + """ + type field predicates + """ + type: DataStorageType + typeNEQ: DataStorageType + typeIn: [DataStorageType!] + typeNotIn: [DataStorageType!] + """ + status field predicates + """ + status: DataStorageStatus + statusNEQ: DataStorageStatus + statusIn: [DataStorageStatus!] + statusNotIn: [DataStorageStatus!] + """ + requests edge predicates + """ + hasRequests: Boolean + hasRequestsWith: [RequestWhereInput!] + """ + executions edge predicates + """ + hasExecutions: Boolean + hasExecutionsWith: [RequestExecutionWhereInput!] +} +""" +The builtin Map type +""" +scalar Map +type Model implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + developer of the model, eg. deeepseek + """ + developer: String! + """ + model id, eg. deeepseek-chat + """ + modelID: String! + """ + model type + """ + type: ModelType! + """ + model name, eg. DeepSeek Chat + """ + name: String! + """ + icon of the model from the lobe-icons, eg. DeepSeek + """ + icon: String! + """ + model group, eg. deepseek + """ + group: String! + modelCard: ModelCard! + settings: ModelSettings! + status: ModelStatus! + """ + User-defined remark or note for the Model + """ + remark: String +} +""" +A connection to a list of items. +""" +type ModelConnection { + """ + A list of edges. + """ + edges: [ModelEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ModelEdge { + """ + The item at the end of the edge. + """ + node: Model + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for Model connections +""" +input ModelOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Models. + """ + field: ModelOrderField! +} +""" +Properties by which Model connections can be ordered. +""" +enum ModelOrderField { + CREATED_AT + UPDATED_AT + NAME +} +""" +ModelStatus is enum for the field status +""" +enum ModelStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/model.Status") { + enabled + disabled + archived +} +""" +ModelType is enum for the field type +""" +enum ModelType @goModel(model: "github.com/looplj/axonhub/internal/ent/model.Type") { + chat + embedding + rerank + image_generation + video_generation +} +""" +ModelWhereInput is used for filtering Model objects. +Input was generated by ent. +""" +input ModelWhereInput { + not: ModelWhereInput + and: [ModelWhereInput!] + or: [ModelWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + developer field predicates + """ + developer: String + developerNEQ: String + developerIn: [String!] + developerNotIn: [String!] + developerGT: String + developerGTE: String + developerLT: String + developerLTE: String + developerContains: String + developerHasPrefix: String + developerHasSuffix: String + developerEqualFold: String + developerContainsFold: String + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + type field predicates + """ + type: ModelType + typeNEQ: ModelType + typeIn: [ModelType!] + typeNotIn: [ModelType!] + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + icon field predicates + """ + icon: String + iconNEQ: String + iconIn: [String!] + iconNotIn: [String!] + iconGT: String + iconGTE: String + iconLT: String + iconLTE: String + iconContains: String + iconHasPrefix: String + iconHasSuffix: String + iconEqualFold: String + iconContainsFold: String + """ + group field predicates + """ + group: String + groupNEQ: String + groupIn: [String!] + groupNotIn: [String!] + groupGT: String + groupGTE: String + groupLT: String + groupLTE: String + groupContains: String + groupHasPrefix: String + groupHasSuffix: String + groupEqualFold: String + groupContainsFold: String + """ + status field predicates + """ + status: ModelStatus + statusNEQ: ModelStatus + statusIn: [ModelStatus!] + statusNotIn: [ModelStatus!] + """ + remark field predicates + """ + remark: String + remarkNEQ: String + remarkIn: [String!] + remarkNotIn: [String!] + remarkGT: String + remarkGTE: String + remarkLT: String + remarkLTE: String + remarkContains: String + remarkHasPrefix: String + remarkHasSuffix: String + remarkIsNil: Boolean + remarkNotNil: Boolean + remarkEqualFold: String + remarkContainsFold: String +} +""" +An object with an ID. +Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) +""" +interface Node @goModel(model: "github.com/looplj/axonhub/internal/ent.Noder") { + """ + The id of the object. + """ + id: ID! +} +""" +Possible directions in which to order a list of items when provided an `orderBy` argument. +""" +enum OrderDirection { + """ + Specifies an ascending order for a given `orderBy` argument. + """ + ASC + """ + Specifies a descending order for a given `orderBy` argument. + """ + DESC +} +""" +Information about pagination in a connection. +https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo +""" +type PageInfo { + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + """ + When paginating backwards, the cursor to continue. + """ + startCursor: Cursor + """ + When paginating forwards, the cursor to continue. + """ + endCursor: Cursor +} +type Project implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + project name + """ + name: String! + """ + project description + """ + description: String! + """ + project status + """ + status: ProjectStatus! + profiles: ProjectProfiles + users( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Users returned from the connection. + """ + orderBy: UserOrder + + """ + Filtering options for Users returned from the connection. + """ + where: UserWhereInput + ): UserConnection! + roles( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Roles returned from the connection. + """ + orderBy: RoleOrder + + """ + Filtering options for Roles returned from the connection. + """ + where: RoleWhereInput + ): RoleConnection! + apiKeys( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for APIKeys returned from the connection. + """ + orderBy: APIKeyOrder + + """ + Filtering options for APIKeys returned from the connection. + """ + where: APIKeyWhereInput + ): APIKeyConnection! + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! + usageLogs( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for UsageLogs returned from the connection. + """ + orderBy: UsageLogOrder + + """ + Filtering options for UsageLogs returned from the connection. + """ + where: UsageLogWhereInput + ): UsageLogConnection! + threads( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Threads returned from the connection. + """ + orderBy: ThreadOrder + + """ + Filtering options for Threads returned from the connection. + """ + where: ThreadWhereInput + ): ThreadConnection! + traces( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Traces returned from the connection. + """ + orderBy: TraceOrder + + """ + Filtering options for Traces returned from the connection. + """ + where: TraceWhereInput + ): TraceConnection! + prompts( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Prompts returned from the connection. + """ + orderBy: PromptOrder + + """ + Filtering options for Prompts returned from the connection. + """ + where: PromptWhereInput + ): PromptConnection! + projectUsers: [UserProject!] +} +""" +A connection to a list of items. +""" +type ProjectConnection { + """ + A list of edges. + """ + edges: [ProjectEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ProjectEdge { + """ + The item at the end of the edge. + """ + node: Project + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for Project connections +""" +input ProjectOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Projects. + """ + field: ProjectOrderField! +} +""" +Properties by which Project connections can be ordered. +""" +enum ProjectOrderField { + CREATED_AT + UPDATED_AT +} +""" +ProjectStatus is enum for the field status +""" +enum ProjectStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/project.Status") { + active + archived +} +""" +ProjectWhereInput is used for filtering Project objects. +Input was generated by ent. +""" +input ProjectWhereInput { + not: ProjectWhereInput + and: [ProjectWhereInput!] + or: [ProjectWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + description field predicates + """ + description: String + descriptionNEQ: String + descriptionIn: [String!] + descriptionNotIn: [String!] + descriptionGT: String + descriptionGTE: String + descriptionLT: String + descriptionLTE: String + descriptionContains: String + descriptionHasPrefix: String + descriptionHasSuffix: String + descriptionEqualFold: String + descriptionContainsFold: String + """ + status field predicates + """ + status: ProjectStatus + statusNEQ: ProjectStatus + statusIn: [ProjectStatus!] + statusNotIn: [ProjectStatus!] + """ + users edge predicates + """ + hasUsers: Boolean + hasUsersWith: [UserWhereInput!] + """ + roles edge predicates + """ + hasRoles: Boolean + hasRolesWith: [RoleWhereInput!] + """ + api_keys edge predicates + """ + hasAPIKeys: Boolean + hasAPIKeysWith: [APIKeyWhereInput!] + """ + requests edge predicates + """ + hasRequests: Boolean + hasRequestsWith: [RequestWhereInput!] + """ + usage_logs edge predicates + """ + hasUsageLogs: Boolean + hasUsageLogsWith: [UsageLogWhereInput!] + """ + threads edge predicates + """ + hasThreads: Boolean + hasThreadsWith: [ThreadWhereInput!] + """ + traces edge predicates + """ + hasTraces: Boolean + hasTracesWith: [TraceWhereInput!] + """ + prompts edge predicates + """ + hasPrompts: Boolean + hasPromptsWith: [PromptWhereInput!] + """ + project_users edge predicates + """ + hasProjectUsers: Boolean + hasProjectUsersWith: [UserProjectWhereInput!] +} +type Prompt implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Project ID that this prompt belongs to + """ + projectID: Int! + """ + prompt name + """ + name: String! + """ + prompt description + """ + description: String! + """ + prompt role + """ + role: String! + """ + prompt content + """ + content: String! + status: PromptStatus! + """ + prompt insertion order, smaller values are inserted first + """ + order: Int! + """ + prompt settings in JSON format + """ + settings: PromptSettings! + projects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Projects returned from the connection. + """ + orderBy: ProjectOrder + + """ + Filtering options for Projects returned from the connection. + """ + where: ProjectWhereInput + ): ProjectConnection! +} +""" +A connection to a list of items. +""" +type PromptConnection { + """ + A list of edges. + """ + edges: [PromptEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type PromptEdge { + """ + The item at the end of the edge. + """ + node: Prompt + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for Prompt connections +""" +input PromptOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Prompts. + """ + field: PromptOrderField! +} +""" +Properties by which Prompt connections can be ordered. +""" +enum PromptOrderField { + CREATED_AT + UPDATED_AT + ORDER +} +type PromptProtectionRule implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Rule name + """ + name: String! + """ + Rule description + """ + description: String! + """ + Regex pattern to match prompt content + """ + pattern: String! + status: PromptProtectionRuleStatus! + """ + Prompt protection rule settings + """ + settings: PromptProtectionSettings! +} +""" +A connection to a list of items. +""" +type PromptProtectionRuleConnection { + """ + A list of edges. + """ + edges: [PromptProtectionRuleEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type PromptProtectionRuleEdge { + """ + The item at the end of the edge. + """ + node: PromptProtectionRule + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for PromptProtectionRule connections +""" +input PromptProtectionRuleOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order PromptProtectionRules. + """ + field: PromptProtectionRuleOrderField! +} +""" +Properties by which PromptProtectionRule connections can be ordered. +""" +enum PromptProtectionRuleOrderField { + CREATED_AT + UPDATED_AT + NAME +} +""" +PromptProtectionRuleStatus is enum for the field status +""" +enum PromptProtectionRuleStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/promptprotectionrule.Status") { + enabled + disabled + archived +} +""" +PromptProtectionRuleWhereInput is used for filtering PromptProtectionRule objects. +Input was generated by ent. +""" +input PromptProtectionRuleWhereInput { + not: PromptProtectionRuleWhereInput + and: [PromptProtectionRuleWhereInput!] + or: [PromptProtectionRuleWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + description field predicates + """ + description: String + descriptionNEQ: String + descriptionIn: [String!] + descriptionNotIn: [String!] + descriptionGT: String + descriptionGTE: String + descriptionLT: String + descriptionLTE: String + descriptionContains: String + descriptionHasPrefix: String + descriptionHasSuffix: String + descriptionEqualFold: String + descriptionContainsFold: String + """ + pattern field predicates + """ + pattern: String + patternNEQ: String + patternIn: [String!] + patternNotIn: [String!] + patternGT: String + patternGTE: String + patternLT: String + patternLTE: String + patternContains: String + patternHasPrefix: String + patternHasSuffix: String + patternEqualFold: String + patternContainsFold: String + """ + status field predicates + """ + status: PromptProtectionRuleStatus + statusNEQ: PromptProtectionRuleStatus + statusIn: [PromptProtectionRuleStatus!] + statusNotIn: [PromptProtectionRuleStatus!] +} +""" +PromptStatus is enum for the field status +""" +enum PromptStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/prompt.Status") { + enabled + disabled +} +""" +PromptWhereInput is used for filtering Prompt objects. +Input was generated by ent. +""" +input PromptWhereInput { + not: PromptWhereInput + and: [PromptWhereInput!] + or: [PromptWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + project_id field predicates + """ + projectID: Int + projectIDNEQ: Int + projectIDIn: [Int!] + projectIDNotIn: [Int!] + projectIDGT: Int + projectIDGTE: Int + projectIDLT: Int + projectIDLTE: Int + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + description field predicates + """ + description: String + descriptionNEQ: String + descriptionIn: [String!] + descriptionNotIn: [String!] + descriptionGT: String + descriptionGTE: String + descriptionLT: String + descriptionLTE: String + descriptionContains: String + descriptionHasPrefix: String + descriptionHasSuffix: String + descriptionEqualFold: String + descriptionContainsFold: String + """ + role field predicates + """ + role: String + roleNEQ: String + roleIn: [String!] + roleNotIn: [String!] + roleGT: String + roleGTE: String + roleLT: String + roleLTE: String + roleContains: String + roleHasPrefix: String + roleHasSuffix: String + roleEqualFold: String + roleContainsFold: String + """ + content field predicates + """ + content: String + contentNEQ: String + contentIn: [String!] + contentNotIn: [String!] + contentGT: String + contentGTE: String + contentLT: String + contentLTE: String + contentContains: String + contentHasPrefix: String + contentHasSuffix: String + contentEqualFold: String + contentContainsFold: String + """ + status field predicates + """ + status: PromptStatus + statusNEQ: PromptStatus + statusIn: [PromptStatus!] + statusNotIn: [PromptStatus!] + """ + order field predicates + """ + order: Int + orderNEQ: Int + orderIn: [Int!] + orderNotIn: [Int!] + orderGT: Int + orderGTE: Int + orderLT: Int + orderLTE: Int + """ + projects edge predicates + """ + hasProjects: Boolean + hasProjectsWith: [ProjectWhereInput!] +} +type ProviderQuotaStatus implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + channelID: ID! + providerType: ProviderQuotaStatusProviderType! + """ + Overall status: available, warning, exhausted, unknown + """ + status: ProviderQuotaStatusStatus! + """ + Provider-specific quota data + """ + quotaData: Map! + """ + Timestamp for next quota reset (primary window) + """ + nextResetAt: Time + """ + True if status is available or warning + """ + ready: Boolean! + """ + Timestamp for next scheduled quota check + """ + nextCheckAt: Time! + channel: Channel! +} +""" +Ordering options for ProviderQuotaStatus connections +""" +input ProviderQuotaStatusOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order ProviderQuotaStatusSlice. + """ + field: ProviderQuotaStatusOrderField! +} +""" +Properties by which ProviderQuotaStatus connections can be ordered. +""" +enum ProviderQuotaStatusOrderField { + CREATED_AT + UPDATED_AT +} +""" +ProviderQuotaStatusProviderType is enum for the field provider_type +""" +enum ProviderQuotaStatusProviderType @goModel(model: "github.com/looplj/axonhub/internal/ent/providerquotastatus.ProviderType") { + claudecode + codex + github_copilot + nanogpt +} +""" +ProviderQuotaStatusStatus is enum for the field status +""" +enum ProviderQuotaStatusStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/providerquotastatus.Status") { + available + warning + exhausted + unknown +} +""" +ProviderQuotaStatusWhereInput is used for filtering ProviderQuotaStatus objects. +Input was generated by ent. +""" +input ProviderQuotaStatusWhereInput { + not: ProviderQuotaStatusWhereInput + and: [ProviderQuotaStatusWhereInput!] + or: [ProviderQuotaStatusWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + """ + provider_type field predicates + """ + providerType: ProviderQuotaStatusProviderType + providerTypeNEQ: ProviderQuotaStatusProviderType + providerTypeIn: [ProviderQuotaStatusProviderType!] + providerTypeNotIn: [ProviderQuotaStatusProviderType!] + """ + status field predicates + """ + status: ProviderQuotaStatusStatus + statusNEQ: ProviderQuotaStatusStatus + statusIn: [ProviderQuotaStatusStatus!] + statusNotIn: [ProviderQuotaStatusStatus!] + """ + next_reset_at field predicates + """ + nextResetAt: Time + nextResetAtNEQ: Time + nextResetAtIn: [Time!] + nextResetAtNotIn: [Time!] + nextResetAtGT: Time + nextResetAtGTE: Time + nextResetAtLT: Time + nextResetAtLTE: Time + nextResetAtIsNil: Boolean + nextResetAtNotNil: Boolean + """ + ready field predicates + """ + ready: Boolean + readyNEQ: Boolean + """ + next_check_at field predicates + """ + nextCheckAt: Time + nextCheckAtNEQ: Time + nextCheckAtIn: [Time!] + nextCheckAtNotIn: [Time!] + nextCheckAtGT: Time + nextCheckAtGTE: Time + nextCheckAtLT: Time + nextCheckAtLTE: Time + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] +} +type Query { + """ + Fetches an object given its ID. + """ + node( + """ + ID of the object. + """ + id: ID! + ): Node + """ + Lookup nodes by a list of IDs. + """ + nodes( + """ + The list of node IDs. + """ + ids: [ID!]! + ): [Node]! + apiKeys( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for APIKeys returned from the connection. + """ + orderBy: APIKeyOrder + + """ + Filtering options for APIKeys returned from the connection. + """ + where: APIKeyWhereInput + ): APIKeyConnection! + channels( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Channels returned from the connection. + """ + orderBy: ChannelOrder + + """ + Filtering options for Channels returned from the connection. + """ + where: ChannelWhereInput + ): ChannelConnection! + channelOverrideTemplates( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for ChannelOverrideTemplates returned from the connection. + """ + orderBy: ChannelOverrideTemplateOrder + + """ + Filtering options for ChannelOverrideTemplates returned from the connection. + """ + where: ChannelOverrideTemplateWhereInput + ): ChannelOverrideTemplateConnection! + dataStorages( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for DataStorages returned from the connection. + """ + orderBy: DataStorageOrder + + """ + Filtering options for DataStorages returned from the connection. + """ + where: DataStorageWhereInput + ): DataStorageConnection! + models( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Models returned from the connection. + """ + orderBy: ModelOrder + + """ + Filtering options for Models returned from the connection. + """ + where: ModelWhereInput + ): ModelConnection! + projects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Projects returned from the connection. + """ + orderBy: ProjectOrder + + """ + Filtering options for Projects returned from the connection. + """ + where: ProjectWhereInput + ): ProjectConnection! + prompts( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Prompts returned from the connection. + """ + orderBy: PromptOrder + + """ + Filtering options for Prompts returned from the connection. + """ + where: PromptWhereInput + ): PromptConnection! + promptProtectionRules( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for PromptProtectionRules returned from the connection. + """ + orderBy: PromptProtectionRuleOrder + + """ + Filtering options for PromptProtectionRules returned from the connection. + """ + where: PromptProtectionRuleWhereInput + ): PromptProtectionRuleConnection! + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! + roles( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Roles returned from the connection. + """ + orderBy: RoleOrder + + """ + Filtering options for Roles returned from the connection. + """ + where: RoleWhereInput + ): RoleConnection! + systems( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Systems returned from the connection. + """ + orderBy: SystemOrder + + """ + Filtering options for Systems returned from the connection. + """ + where: SystemWhereInput + ): SystemConnection! + threads( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Threads returned from the connection. + """ + orderBy: ThreadOrder + + """ + Filtering options for Threads returned from the connection. + """ + where: ThreadWhereInput + ): ThreadConnection! + traces( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Traces returned from the connection. + """ + orderBy: TraceOrder + + """ + Filtering options for Traces returned from the connection. + """ + where: TraceWhereInput + ): TraceConnection! + usageLogs( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for UsageLogs returned from the connection. + """ + orderBy: UsageLogOrder + + """ + Filtering options for UsageLogs returned from the connection. + """ + where: UsageLogWhereInput + ): UsageLogConnection! + users( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Users returned from the connection. + """ + orderBy: UserOrder + + """ + Filtering options for Users returned from the connection. + """ + where: UserWhereInput + ): UserConnection! +} +type Request implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + API Key ID of the request, null for the request from the Admin. + """ + apiKeyID: ID + """ + Project ID, default to 1 for backward compatibility + """ + projectID: ID! + """ + Trace ID that this request belongs to + """ + traceID: ID + """ + Data Storage ID that this request belongs to + """ + dataStorageID: ID + source: RequestSource! + modelID: String! + format: String! + """ + Request headers + """ + requestHeaders: JSONRawMessage + requestBody: JSONRawMessage! @goField(forceResolver: true) + responseBody: JSONRawMessage @goField(forceResolver: true) + responseChunks: [JSONRawMessage!] @goField(forceResolver: true) + channelID: ID + externalID: String + status: RequestStatus! + stream: Boolean! + clientIP: String! + metricsLatencyMs: Int + metricsFirstTokenLatencyMs: Int + """ + Reasoning/thinking duration in milliseconds + """ + metricsReasoningDurationMs: Int + """ + whether the generated content has been saved to external storage + """ + contentSaved: Boolean! + """ + data storage id used to save the content file + """ + contentStorageID: Int + """ + storage key/path of the saved content file + """ + contentStorageKey: String + """ + when the content file was saved + """ + contentSavedAt: Time + apiKey: APIKey + project: Project! + trace: Trace + dataStorage: DataStorage + executions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for RequestExecutions returned from the connection. + """ + orderBy: RequestExecutionOrder + + """ + Filtering options for RequestExecutions returned from the connection. + """ + where: RequestExecutionWhereInput + ): RequestExecutionConnection! + channel: Channel @goField(forceResolver: true) + usageLogs( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for UsageLogs returned from the connection. + """ + orderBy: UsageLogOrder + + """ + Filtering options for UsageLogs returned from the connection. + """ + where: UsageLogWhereInput + ): UsageLogConnection! +} +""" +A connection to a list of items. +""" +type RequestConnection { + """ + A list of edges. + """ + edges: [RequestEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type RequestEdge { + """ + The item at the end of the edge. + """ + node: Request + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +type RequestExecution implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + projectID: Int! + requestID: ID! + channelID: ID + """ + Data Storage ID that this request belongs to + """ + dataStorageID: ID + externalID: String + modelID: String! + format: String! + requestBody: JSONRawMessage! @goField(forceResolver: true) + responseBody: JSONRawMessage @goField(forceResolver: true) + responseChunks: [JSONRawMessage!] @goField(forceResolver: true) + errorMessage: String + """ + HTTP status code from the upstream provider + """ + responseStatusCode: Int + status: RequestExecutionStatus! + stream: Boolean! + metricsLatencyMs: Int + metricsFirstTokenLatencyMs: Int + """ + Reasoning/thinking duration in milliseconds + """ + metricsReasoningDurationMs: Int + """ + Request headers + """ + requestHeaders: JSONRawMessage + request: Request! + channel: Channel @goField(forceResolver: true) + dataStorage: DataStorage +} +""" +A connection to a list of items. +""" +type RequestExecutionConnection { + """ + A list of edges. + """ + edges: [RequestExecutionEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type RequestExecutionEdge { + """ + The item at the end of the edge. + """ + node: RequestExecution + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for RequestExecution connections +""" +input RequestExecutionOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order RequestExecutions. + """ + field: RequestExecutionOrderField! +} +""" +Properties by which RequestExecution connections can be ordered. +""" +enum RequestExecutionOrderField { + CREATED_AT + UPDATED_AT +} +""" +RequestExecutionStatus is enum for the field status +""" +enum RequestExecutionStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/requestexecution.Status") { + pending + processing + completed + failed + canceled +} +""" +RequestExecutionWhereInput is used for filtering RequestExecution objects. +Input was generated by ent. +""" +input RequestExecutionWhereInput { + not: RequestExecutionWhereInput + and: [RequestExecutionWhereInput!] + or: [RequestExecutionWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + project_id field predicates + """ + projectID: Int + projectIDNEQ: Int + projectIDIn: [Int!] + projectIDNotIn: [Int!] + projectIDGT: Int + projectIDGTE: Int + projectIDLT: Int + projectIDLTE: Int + """ + request_id field predicates + """ + requestID: ID + requestIDNEQ: ID + requestIDIn: [ID!] + requestIDNotIn: [ID!] + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + channelIDIsNil: Boolean + channelIDNotNil: Boolean + """ + data_storage_id field predicates + """ + dataStorageID: ID + dataStorageIDNEQ: ID + dataStorageIDIn: [ID!] + dataStorageIDNotIn: [ID!] + dataStorageIDIsNil: Boolean + dataStorageIDNotNil: Boolean + """ + external_id field predicates + """ + externalID: String + externalIDNEQ: String + externalIDIn: [String!] + externalIDNotIn: [String!] + externalIDGT: String + externalIDGTE: String + externalIDLT: String + externalIDLTE: String + externalIDContains: String + externalIDHasPrefix: String + externalIDHasSuffix: String + externalIDIsNil: Boolean + externalIDNotNil: Boolean + externalIDEqualFold: String + externalIDContainsFold: String + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + format field predicates + """ + format: String + formatNEQ: String + formatIn: [String!] + formatNotIn: [String!] + formatGT: String + formatGTE: String + formatLT: String + formatLTE: String + formatContains: String + formatHasPrefix: String + formatHasSuffix: String + formatEqualFold: String + formatContainsFold: String + """ + error_message field predicates + """ + errorMessage: String + errorMessageNEQ: String + errorMessageIn: [String!] + errorMessageNotIn: [String!] + errorMessageGT: String + errorMessageGTE: String + errorMessageLT: String + errorMessageLTE: String + errorMessageContains: String + errorMessageHasPrefix: String + errorMessageHasSuffix: String + errorMessageIsNil: Boolean + errorMessageNotNil: Boolean + errorMessageEqualFold: String + errorMessageContainsFold: String + """ + response_status_code field predicates + """ + responseStatusCode: Int + responseStatusCodeNEQ: Int + responseStatusCodeIn: [Int!] + responseStatusCodeNotIn: [Int!] + responseStatusCodeGT: Int + responseStatusCodeGTE: Int + responseStatusCodeLT: Int + responseStatusCodeLTE: Int + responseStatusCodeIsNil: Boolean + responseStatusCodeNotNil: Boolean + """ + status field predicates + """ + status: RequestExecutionStatus + statusNEQ: RequestExecutionStatus + statusIn: [RequestExecutionStatus!] + statusNotIn: [RequestExecutionStatus!] + """ + stream field predicates + """ + stream: Boolean + streamNEQ: Boolean + """ + metrics_latency_ms field predicates + """ + metricsLatencyMs: Int + metricsLatencyMsNEQ: Int + metricsLatencyMsIn: [Int!] + metricsLatencyMsNotIn: [Int!] + metricsLatencyMsGT: Int + metricsLatencyMsGTE: Int + metricsLatencyMsLT: Int + metricsLatencyMsLTE: Int + metricsLatencyMsIsNil: Boolean + metricsLatencyMsNotNil: Boolean + """ + metrics_first_token_latency_ms field predicates + """ + metricsFirstTokenLatencyMs: Int + metricsFirstTokenLatencyMsNEQ: Int + metricsFirstTokenLatencyMsIn: [Int!] + metricsFirstTokenLatencyMsNotIn: [Int!] + metricsFirstTokenLatencyMsGT: Int + metricsFirstTokenLatencyMsGTE: Int + metricsFirstTokenLatencyMsLT: Int + metricsFirstTokenLatencyMsLTE: Int + metricsFirstTokenLatencyMsIsNil: Boolean + metricsFirstTokenLatencyMsNotNil: Boolean + """ + metrics_reasoning_duration_ms field predicates + """ + metricsReasoningDurationMs: Int + metricsReasoningDurationMsNEQ: Int + metricsReasoningDurationMsIn: [Int!] + metricsReasoningDurationMsNotIn: [Int!] + metricsReasoningDurationMsGT: Int + metricsReasoningDurationMsGTE: Int + metricsReasoningDurationMsLT: Int + metricsReasoningDurationMsLTE: Int + metricsReasoningDurationMsIsNil: Boolean + metricsReasoningDurationMsNotNil: Boolean + """ + request edge predicates + """ + hasRequest: Boolean + hasRequestWith: [RequestWhereInput!] + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] + """ + data_storage edge predicates + """ + hasDataStorage: Boolean + hasDataStorageWith: [DataStorageWhereInput!] +} +""" +Ordering options for Request connections +""" +input RequestOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Requests. + """ + field: RequestOrderField! +} +""" +Properties by which Request connections can be ordered. +""" +enum RequestOrderField { + CREATED_AT + UPDATED_AT +} +""" +RequestSource is enum for the field source +""" +enum RequestSource @goModel(model: "github.com/looplj/axonhub/internal/ent/request.Source") { + api + playground + test +} +""" +RequestStatus is enum for the field status +""" +enum RequestStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/request.Status") { + pending + processing + completed + failed + canceled +} +""" +RequestWhereInput is used for filtering Request objects. +Input was generated by ent. +""" +input RequestWhereInput { + not: RequestWhereInput + and: [RequestWhereInput!] + or: [RequestWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + api_key_id field predicates + """ + apiKeyID: ID + apiKeyIDNEQ: ID + apiKeyIDIn: [ID!] + apiKeyIDNotIn: [ID!] + apiKeyIDIsNil: Boolean + apiKeyIDNotNil: Boolean + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + """ + trace_id field predicates + """ + traceID: ID + traceIDNEQ: ID + traceIDIn: [ID!] + traceIDNotIn: [ID!] + traceIDIsNil: Boolean + traceIDNotNil: Boolean + """ + data_storage_id field predicates + """ + dataStorageID: ID + dataStorageIDNEQ: ID + dataStorageIDIn: [ID!] + dataStorageIDNotIn: [ID!] + dataStorageIDIsNil: Boolean + dataStorageIDNotNil: Boolean + """ + source field predicates + """ + source: RequestSource + sourceNEQ: RequestSource + sourceIn: [RequestSource!] + sourceNotIn: [RequestSource!] + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + format field predicates + """ + format: String + formatNEQ: String + formatIn: [String!] + formatNotIn: [String!] + formatGT: String + formatGTE: String + formatLT: String + formatLTE: String + formatContains: String + formatHasPrefix: String + formatHasSuffix: String + formatEqualFold: String + formatContainsFold: String + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + channelIDIsNil: Boolean + channelIDNotNil: Boolean + """ + external_id field predicates + """ + externalID: String + externalIDNEQ: String + externalIDIn: [String!] + externalIDNotIn: [String!] + externalIDGT: String + externalIDGTE: String + externalIDLT: String + externalIDLTE: String + externalIDContains: String + externalIDHasPrefix: String + externalIDHasSuffix: String + externalIDIsNil: Boolean + externalIDNotNil: Boolean + externalIDEqualFold: String + externalIDContainsFold: String + """ + status field predicates + """ + status: RequestStatus + statusNEQ: RequestStatus + statusIn: [RequestStatus!] + statusNotIn: [RequestStatus!] + """ + stream field predicates + """ + stream: Boolean + streamNEQ: Boolean + """ + client_ip field predicates + """ + clientIP: String + clientIPNEQ: String + clientIPIn: [String!] + clientIPNotIn: [String!] + clientIPGT: String + clientIPGTE: String + clientIPLT: String + clientIPLTE: String + clientIPContains: String + clientIPHasPrefix: String + clientIPHasSuffix: String + clientIPEqualFold: String + clientIPContainsFold: String + """ + metrics_latency_ms field predicates + """ + metricsLatencyMs: Int + metricsLatencyMsNEQ: Int + metricsLatencyMsIn: [Int!] + metricsLatencyMsNotIn: [Int!] + metricsLatencyMsGT: Int + metricsLatencyMsGTE: Int + metricsLatencyMsLT: Int + metricsLatencyMsLTE: Int + metricsLatencyMsIsNil: Boolean + metricsLatencyMsNotNil: Boolean + """ + metrics_first_token_latency_ms field predicates + """ + metricsFirstTokenLatencyMs: Int + metricsFirstTokenLatencyMsNEQ: Int + metricsFirstTokenLatencyMsIn: [Int!] + metricsFirstTokenLatencyMsNotIn: [Int!] + metricsFirstTokenLatencyMsGT: Int + metricsFirstTokenLatencyMsGTE: Int + metricsFirstTokenLatencyMsLT: Int + metricsFirstTokenLatencyMsLTE: Int + metricsFirstTokenLatencyMsIsNil: Boolean + metricsFirstTokenLatencyMsNotNil: Boolean + """ + metrics_reasoning_duration_ms field predicates + """ + metricsReasoningDurationMs: Int + metricsReasoningDurationMsNEQ: Int + metricsReasoningDurationMsIn: [Int!] + metricsReasoningDurationMsNotIn: [Int!] + metricsReasoningDurationMsGT: Int + metricsReasoningDurationMsGTE: Int + metricsReasoningDurationMsLT: Int + metricsReasoningDurationMsLTE: Int + metricsReasoningDurationMsIsNil: Boolean + metricsReasoningDurationMsNotNil: Boolean + """ + content_saved field predicates + """ + contentSaved: Boolean + contentSavedNEQ: Boolean + """ + content_storage_id field predicates + """ + contentStorageID: Int + contentStorageIDNEQ: Int + contentStorageIDIn: [Int!] + contentStorageIDNotIn: [Int!] + contentStorageIDGT: Int + contentStorageIDGTE: Int + contentStorageIDLT: Int + contentStorageIDLTE: Int + contentStorageIDIsNil: Boolean + contentStorageIDNotNil: Boolean + """ + content_storage_key field predicates + """ + contentStorageKey: String + contentStorageKeyNEQ: String + contentStorageKeyIn: [String!] + contentStorageKeyNotIn: [String!] + contentStorageKeyGT: String + contentStorageKeyGTE: String + contentStorageKeyLT: String + contentStorageKeyLTE: String + contentStorageKeyContains: String + contentStorageKeyHasPrefix: String + contentStorageKeyHasSuffix: String + contentStorageKeyIsNil: Boolean + contentStorageKeyNotNil: Boolean + contentStorageKeyEqualFold: String + contentStorageKeyContainsFold: String + """ + content_saved_at field predicates + """ + contentSavedAt: Time + contentSavedAtNEQ: Time + contentSavedAtIn: [Time!] + contentSavedAtNotIn: [Time!] + contentSavedAtGT: Time + contentSavedAtGTE: Time + contentSavedAtLT: Time + contentSavedAtLTE: Time + contentSavedAtIsNil: Boolean + contentSavedAtNotNil: Boolean + """ + api_key edge predicates + """ + hasAPIKey: Boolean + hasAPIKeyWith: [APIKeyWhereInput!] + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + trace edge predicates + """ + hasTrace: Boolean + hasTraceWith: [TraceWhereInput!] + """ + data_storage edge predicates + """ + hasDataStorage: Boolean + hasDataStorageWith: [DataStorageWhereInput!] + """ + executions edge predicates + """ + hasExecutions: Boolean + hasExecutionsWith: [RequestExecutionWhereInput!] + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] + """ + usage_logs edge predicates + """ + hasUsageLogs: Boolean + hasUsageLogsWith: [UsageLogWhereInput!] +} +type Role implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + name: String! + """ + Role level: system or project + """ + level: RoleLevel! + """ + Project ID for project-level roles, 0 for system roles, it is used to make the role unique in system level. + """ + projectID: ID + """ + Available scopes for this role: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + users( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Users returned from the connection. + """ + orderBy: UserOrder + + """ + Filtering options for Users returned from the connection. + """ + where: UserWhereInput + ): UserConnection! + project: Project + userRoles: [UserRole!] +} +""" +A connection to a list of items. +""" +type RoleConnection { + """ + A list of edges. + """ + edges: [RoleEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type RoleEdge { + """ + The item at the end of the edge. + """ + node: Role + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +RoleLevel is enum for the field level +""" +enum RoleLevel @goModel(model: "github.com/looplj/axonhub/internal/ent/role.Level") { + system + project +} +""" +Ordering options for Role connections +""" +input RoleOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Roles. + """ + field: RoleOrderField! +} +""" +Properties by which Role connections can be ordered. +""" +enum RoleOrderField { + CREATED_AT + UPDATED_AT +} +""" +RoleWhereInput is used for filtering Role objects. +Input was generated by ent. +""" +input RoleWhereInput { + not: RoleWhereInput + and: [RoleWhereInput!] + or: [RoleWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + name field predicates + """ + name: String + nameNEQ: String + nameIn: [String!] + nameNotIn: [String!] + nameGT: String + nameGTE: String + nameLT: String + nameLTE: String + nameContains: String + nameHasPrefix: String + nameHasSuffix: String + nameEqualFold: String + nameContainsFold: String + """ + level field predicates + """ + level: RoleLevel + levelNEQ: RoleLevel + levelIn: [RoleLevel!] + levelNotIn: [RoleLevel!] + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + projectIDIsNil: Boolean + projectIDNotNil: Boolean + """ + users edge predicates + """ + hasUsers: Boolean + hasUsersWith: [UserWhereInput!] + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + user_roles edge predicates + """ + hasUserRoles: Boolean + hasUserRolesWith: [UserRoleWhereInput!] +} +type System implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + key: String! + value: String! +} +""" +A connection to a list of items. +""" +type SystemConnection { + """ + A list of edges. + """ + edges: [SystemEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type SystemEdge { + """ + The item at the end of the edge. + """ + node: System + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for System connections +""" +input SystemOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Systems. + """ + field: SystemOrderField! +} +""" +Properties by which System connections can be ordered. +""" +enum SystemOrderField { + CREATED_AT + UPDATED_AT +} +""" +SystemWhereInput is used for filtering System objects. +Input was generated by ent. +""" +input SystemWhereInput { + not: SystemWhereInput + and: [SystemWhereInput!] + or: [SystemWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + key field predicates + """ + key: String + keyNEQ: String + keyIn: [String!] + keyNotIn: [String!] + keyGT: String + keyGTE: String + keyLT: String + keyLTE: String + keyContains: String + keyHasPrefix: String + keyHasSuffix: String + keyEqualFold: String + keyContainsFold: String + """ + value field predicates + """ + value: String + valueNEQ: String + valueIn: [String!] + valueNotIn: [String!] + valueGT: String + valueGTE: String + valueLT: String + valueLTE: String + valueContains: String + valueHasPrefix: String + valueHasSuffix: String + valueEqualFold: String + valueContainsFold: String +} +type Thread implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Project ID that this thread belongs to + """ + projectID: ID! + """ + Unique thread identifier for this thread + """ + threadID: String! + project: Project! + traces( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Traces returned from the connection. + """ + orderBy: TraceOrder + + """ + Filtering options for Traces returned from the connection. + """ + where: TraceWhereInput + ): TraceConnection! +} +""" +A connection to a list of items. +""" +type ThreadConnection { + """ + A list of edges. + """ + edges: [ThreadEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type ThreadEdge { + """ + The item at the end of the edge. + """ + node: Thread + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for Thread connections +""" +input ThreadOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Threads. + """ + field: ThreadOrderField! +} +""" +Properties by which Thread connections can be ordered. +""" +enum ThreadOrderField { + CREATED_AT + UPDATED_AT +} +""" +ThreadWhereInput is used for filtering Thread objects. +Input was generated by ent. +""" +input ThreadWhereInput { + not: ThreadWhereInput + and: [ThreadWhereInput!] + or: [ThreadWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + """ + thread_id field predicates + """ + threadID: String + threadIDNEQ: String + threadIDIn: [String!] + threadIDNotIn: [String!] + threadIDGT: String + threadIDGTE: String + threadIDLT: String + threadIDLTE: String + threadIDContains: String + threadIDHasPrefix: String + threadIDHasSuffix: String + threadIDEqualFold: String + threadIDContainsFold: String + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + traces edge predicates + """ + hasTraces: Boolean + hasTracesWith: [TraceWhereInput!] +} +""" +The builtin Time type +""" +scalar Time +type Trace implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Project ID that this trace belongs to + """ + projectID: ID! + """ + Unique trace identifier + """ + traceID: String! + """ + Thread ID that this trace belongs to + """ + threadID: ID + project: Project! + thread: Thread + requests( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Requests returned from the connection. + """ + orderBy: RequestOrder + + """ + Filtering options for Requests returned from the connection. + """ + where: RequestWhereInput + ): RequestConnection! +} +""" +A connection to a list of items. +""" +type TraceConnection { + """ + A list of edges. + """ + edges: [TraceEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type TraceEdge { + """ + The item at the end of the edge. + """ + node: Trace + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for Trace connections +""" +input TraceOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Traces. + """ + field: TraceOrderField! +} +""" +Properties by which Trace connections can be ordered. +""" +enum TraceOrderField { + CREATED_AT + UPDATED_AT +} +""" +TraceWhereInput is used for filtering Trace objects. +Input was generated by ent. +""" +input TraceWhereInput { + not: TraceWhereInput + and: [TraceWhereInput!] + or: [TraceWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + """ + trace_id field predicates + """ + traceID: String + traceIDNEQ: String + traceIDIn: [String!] + traceIDNotIn: [String!] + traceIDGT: String + traceIDGTE: String + traceIDLT: String + traceIDLTE: String + traceIDContains: String + traceIDHasPrefix: String + traceIDHasSuffix: String + traceIDEqualFold: String + traceIDContainsFold: String + """ + thread_id field predicates + """ + threadID: ID + threadIDNEQ: ID + threadIDIn: [ID!] + threadIDNotIn: [ID!] + threadIDIsNil: Boolean + threadIDNotNil: Boolean + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + thread edge predicates + """ + hasThread: Boolean + hasThreadWith: [ThreadWhereInput!] + """ + requests edge predicates + """ + hasRequests: Boolean + hasRequestsWith: [RequestWhereInput!] +} +""" +UpdateAPIKeyInput is used for update APIKey object. +Input was generated by ent. +""" +input UpdateAPIKeyInput { + name: String + """ + API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes. + """ + scopes: [String!] + appendScopes: [String!] + clearScopes: Boolean +} +""" +UpdateChannelInput is used for update Channel object. +Input was generated by ent. +""" +input UpdateChannelInput { + type: ChannelType + baseURL: String + clearBaseURL: Boolean + name: String + status: ChannelStatus + credentials: ChannelCredentialsInput + supportedModels: [String!] + appendSupportedModels: [String!] + manualModels: [String!] + appendManualModels: [String!] + clearManualModels: Boolean + autoSyncSupportedModels: Boolean + """ + Regex pattern to filter models during auto-sync. Empty string means no filtering. + """ + autoSyncModelPattern: String + clearAutoSyncModelPattern: Boolean + tags: [String!] + appendTags: [String!] + clearTags: Boolean + defaultTestModel: String + policies: ChannelPoliciesInput + clearPolicies: Boolean + settings: ChannelSettingsInput + clearSettings: Boolean + """ + Ordering weight for display sorting + """ + orderingWeight: Int + errorMessage: String + clearErrorMessage: Boolean + """ + User-defined remark or note for the channel + """ + remark: String + clearRemark: Boolean +} +""" +UpdateChannelOverrideTemplateInput is used for update ChannelOverrideTemplate object. +Input was generated by ent. +""" +input UpdateChannelOverrideTemplateInput { + """ + Template name, unique per user + """ + name: String + """ + Template description + """ + description: String + clearDescription: Boolean + """ + Override request headers + """ + headerOverrideOperations: [OverrideOperationInput!] + appendHeaderOverrideOperations: [OverrideOperationInput!] + clearHeaderOverrideOperations: Boolean + """ + Override request body parameters + """ + bodyOverrideOperations: [OverrideOperationInput!] + appendBodyOverrideOperations: [OverrideOperationInput!] + clearBodyOverrideOperations: Boolean +} +""" +UpdateDataStorageInput is used for update DataStorage object. +Input was generated by ent. +""" +input UpdateDataStorageInput { + """ + data source name + """ + name: String + """ + data source description + """ + description: String + """ + data source setting + """ + settings: DataStorageSettingsInput + """ + data source status + """ + status: DataStorageStatus +} +""" +UpdateModelInput is used for update Model object. +Input was generated by ent. +""" +input UpdateModelInput { + """ + developer of the model, eg. deeepseek + """ + developer: String + """ + model id, eg. deeepseek-chat + """ + modelID: String + """ + model type + """ + type: ModelType + """ + model name, eg. DeepSeek Chat + """ + name: String + """ + icon of the model from the lobe-icons, eg. DeepSeek + """ + icon: String + """ + model group, eg. deepseek + """ + group: String + modelCard: ModelCardInput + settings: ModelSettingsInput + status: ModelStatus + """ + User-defined remark or note for the Model + """ + remark: String + clearRemark: Boolean +} +""" +UpdateProjectInput is used for update Project object. +Input was generated by ent. +""" +input UpdateProjectInput { + """ + project name + """ + name: String + """ + project description + """ + description: String + """ + project status + """ + status: ProjectStatus + addUserIDs: [ID!] + removeUserIDs: [ID!] + clearUsers: Boolean +} +""" +UpdatePromptInput is used for update Prompt object. +Input was generated by ent. +""" +input UpdatePromptInput { + """ + prompt name + """ + name: String + """ + prompt description + """ + description: String + """ + prompt role + """ + role: String + """ + prompt content + """ + content: String + status: PromptStatus + """ + prompt insertion order, smaller values are inserted first + """ + order: Int + """ + prompt settings in JSON format + """ + settings: PromptSettingsInput + addProjectIDs: [ID!] + removeProjectIDs: [ID!] + clearProjects: Boolean +} +""" +UpdatePromptProtectionRuleInput is used for update PromptProtectionRule object. +Input was generated by ent. +""" +input UpdatePromptProtectionRuleInput { + """ + Rule name + """ + name: String + """ + Rule description + """ + description: String + """ + Regex pattern to match prompt content + """ + pattern: String + status: PromptProtectionRuleStatus + """ + Prompt protection rule settings + """ + settings: PromptProtectionSettingsInput +} +""" +UpdateRequestInput is used for update Request object. +Input was generated by ent. +""" +input UpdateRequestInput { + """ + Request headers + """ + requestHeaders: JSONRawMessageInput + appendRequestHeaders: JSONRawMessageInput + clearRequestHeaders: Boolean + responseBody: JSONRawMessageInput + appendResponseBody: JSONRawMessageInput + clearResponseBody: Boolean + responseChunks: [JSONRawMessageInput!] + appendResponseChunks: [JSONRawMessageInput!] + clearResponseChunks: Boolean + externalID: String + clearExternalID: Boolean + status: RequestStatus + metricsLatencyMs: Int + clearMetricsLatencyMs: Boolean + metricsFirstTokenLatencyMs: Int + clearMetricsFirstTokenLatencyMs: Boolean + """ + Reasoning/thinking duration in milliseconds + """ + metricsReasoningDurationMs: Int + clearMetricsReasoningDurationMs: Boolean + """ + whether the generated content has been saved to external storage + """ + contentSaved: Boolean + """ + data storage id used to save the content file + """ + contentStorageID: Int + clearContentStorageID: Boolean + """ + storage key/path of the saved content file + """ + contentStorageKey: String + clearContentStorageKey: Boolean + """ + when the content file was saved + """ + contentSavedAt: Time + clearContentSavedAt: Boolean + channelID: ID + clearChannel: Boolean +} +""" +UpdateRoleInput is used for update Role object. +Input was generated by ent. +""" +input UpdateRoleInput { + name: String + """ + Available scopes for this role: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + appendScopes: [String!] + clearScopes: Boolean + addUserIDs: [ID!] + removeUserIDs: [ID!] + clearUsers: Boolean + projectID: ID + clearProject: Boolean +} +""" +UpdateSystemInput is used for update System object. +Input was generated by ent. +""" +input UpdateSystemInput { + key: String + value: String +} +""" +UpdateThreadInput is used for update Thread object. +Input was generated by ent. +""" +input UpdateThreadInput { + """ + Unique thread identifier for this thread + """ + threadID: String +} +""" +UpdateTraceInput is used for update Trace object. +Input was generated by ent. +""" +input UpdateTraceInput { + """ + Unique trace identifier + """ + traceID: String +} +""" +UpdateUsageLogInput is used for update UsageLog object. +Input was generated by ent. +""" +input UpdateUsageLogInput { + """ + Number of tokens in the prompt + """ + promptTokens: Int + """ + Number of tokens in the completion + """ + completionTokens: Int + """ + Total number of tokens used + """ + totalTokens: Int + """ + Number of audio tokens in the prompt + """ + promptAudioTokens: Int + clearPromptAudioTokens: Boolean + """ + Number of cached tokens in the prompt + """ + promptCachedTokens: Int + clearPromptCachedTokens: Boolean + """ + Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h + """ + promptWriteCachedTokens: Int + clearPromptWriteCachedTokens: Boolean + """ + Number of token write cache with 5m ttl + """ + promptWriteCachedTokens5m: Int + clearPromptWriteCachedTokens5m: Boolean + """ + Number of token write cache with 1h ttl + """ + promptWriteCachedTokens1h: Int + clearPromptWriteCachedTokens1h: Boolean + """ + Number of audio tokens in the completion + """ + completionAudioTokens: Int + clearCompletionAudioTokens: Boolean + """ + Number of reasoning tokens in the completion + """ + completionReasoningTokens: Int + clearCompletionReasoningTokens: Boolean + """ + Number of accepted prediction tokens + """ + completionAcceptedPredictionTokens: Int + clearCompletionAcceptedPredictionTokens: Boolean + """ + Number of rejected prediction tokens + """ + completionRejectedPredictionTokens: Int + clearCompletionRejectedPredictionTokens: Boolean + """ + Total cost calculated based on channel model price + """ + totalCost: Float + clearTotalCost: Boolean + """ + Detailed cost breakdown items in JSON + """ + costItems: [CostItemInput!] + appendCostItems: [CostItemInput!] + clearCostItems: Boolean + """ + Reference ID to the channel model price version used for cost calculation + """ + costPriceReferenceID: String + clearCostPriceReferenceID: Boolean +} +""" +UpdateUserInput is used for update User object. +Input was generated by ent. +""" +input UpdateUserInput { + email: String + status: UserStatus + """ + 用户偏好语言 + """ + preferLanguage: String + password: String + firstName: String + lastName: String + """ + 用户头像URL + """ + avatar: String + clearAvatar: Boolean + isOwner: Boolean + """ + User scopes in system level: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + appendScopes: [String!] + clearScopes: Boolean + addProjectIDs: [ID!] + removeProjectIDs: [ID!] + clearProjects: Boolean + addRoleIDs: [ID!] + removeRoleIDs: [ID!] + clearRoles: Boolean +} +type UsageLog implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + """ + Related request ID + """ + requestID: ID! + apiKeyID: Int + """ + Project ID, default to 1 for backward compatibility + """ + projectID: ID! + """ + Channel ID used for the request + """ + channelID: ID + """ + Model identifier used for the request + """ + modelID: String! + """ + Number of tokens in the prompt + """ + promptTokens: Int! + """ + Number of tokens in the completion + """ + completionTokens: Int! + """ + Total number of tokens used + """ + totalTokens: Int! + """ + Number of audio tokens in the prompt + """ + promptAudioTokens: Int + """ + Number of cached tokens in the prompt + """ + promptCachedTokens: Int + """ + Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h + """ + promptWriteCachedTokens: Int + """ + Number of token write cache with 5m ttl + """ + promptWriteCachedTokens5m: Int + """ + Number of token write cache with 1h ttl + """ + promptWriteCachedTokens1h: Int + """ + Number of audio tokens in the completion + """ + completionAudioTokens: Int + """ + Number of reasoning tokens in the completion + """ + completionReasoningTokens: Int + """ + Number of accepted prediction tokens + """ + completionAcceptedPredictionTokens: Int + """ + Number of rejected prediction tokens + """ + completionRejectedPredictionTokens: Int + """ + Source of the request + """ + source: UsageLogSource! + """ + Request format used + """ + format: String! + """ + Total cost calculated based on channel model price + """ + totalCost: Float + """ + Detailed cost breakdown items in JSON + """ + costItems: [CostItem!] + """ + Reference ID to the channel model price version used for cost calculation + """ + costPriceReferenceID: String + request: Request! + project: Project! + channel: Channel @goField(forceResolver: true) +} +""" +A connection to a list of items. +""" +type UsageLogConnection { + """ + A list of edges. + """ + edges: [UsageLogEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type UsageLogEdge { + """ + The item at the end of the edge. + """ + node: UsageLog + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for UsageLog connections +""" +input UsageLogOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order UsageLogs. + """ + field: UsageLogOrderField! +} +""" +Properties by which UsageLog connections can be ordered. +""" +enum UsageLogOrderField { + CREATED_AT + UPDATED_AT +} +""" +UsageLogSource is enum for the field source +""" +enum UsageLogSource @goModel(model: "github.com/looplj/axonhub/internal/ent/usagelog.Source") { + api + playground + test +} +""" +UsageLogWhereInput is used for filtering UsageLog objects. +Input was generated by ent. +""" +input UsageLogWhereInput { + not: UsageLogWhereInput + and: [UsageLogWhereInput!] + or: [UsageLogWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + request_id field predicates + """ + requestID: ID + requestIDNEQ: ID + requestIDIn: [ID!] + requestIDNotIn: [ID!] + """ + api_key_id field predicates + """ + apiKeyID: Int + apiKeyIDNEQ: Int + apiKeyIDIn: [Int!] + apiKeyIDNotIn: [Int!] + apiKeyIDGT: Int + apiKeyIDGTE: Int + apiKeyIDLT: Int + apiKeyIDLTE: Int + apiKeyIDIsNil: Boolean + apiKeyIDNotNil: Boolean + """ + project_id field predicates + """ + projectID: ID + projectIDNEQ: ID + projectIDIn: [ID!] + projectIDNotIn: [ID!] + """ + channel_id field predicates + """ + channelID: ID + channelIDNEQ: ID + channelIDIn: [ID!] + channelIDNotIn: [ID!] + channelIDIsNil: Boolean + channelIDNotNil: Boolean + """ + model_id field predicates + """ + modelID: String + modelIDNEQ: String + modelIDIn: [String!] + modelIDNotIn: [String!] + modelIDGT: String + modelIDGTE: String + modelIDLT: String + modelIDLTE: String + modelIDContains: String + modelIDHasPrefix: String + modelIDHasSuffix: String + modelIDEqualFold: String + modelIDContainsFold: String + """ + prompt_tokens field predicates + """ + promptTokens: Int + promptTokensNEQ: Int + promptTokensIn: [Int!] + promptTokensNotIn: [Int!] + promptTokensGT: Int + promptTokensGTE: Int + promptTokensLT: Int + promptTokensLTE: Int + """ + completion_tokens field predicates + """ + completionTokens: Int + completionTokensNEQ: Int + completionTokensIn: [Int!] + completionTokensNotIn: [Int!] + completionTokensGT: Int + completionTokensGTE: Int + completionTokensLT: Int + completionTokensLTE: Int + """ + total_tokens field predicates + """ + totalTokens: Int + totalTokensNEQ: Int + totalTokensIn: [Int!] + totalTokensNotIn: [Int!] + totalTokensGT: Int + totalTokensGTE: Int + totalTokensLT: Int + totalTokensLTE: Int + """ + prompt_audio_tokens field predicates + """ + promptAudioTokens: Int + promptAudioTokensNEQ: Int + promptAudioTokensIn: [Int!] + promptAudioTokensNotIn: [Int!] + promptAudioTokensGT: Int + promptAudioTokensGTE: Int + promptAudioTokensLT: Int + promptAudioTokensLTE: Int + promptAudioTokensIsNil: Boolean + promptAudioTokensNotNil: Boolean + """ + prompt_cached_tokens field predicates + """ + promptCachedTokens: Int + promptCachedTokensNEQ: Int + promptCachedTokensIn: [Int!] + promptCachedTokensNotIn: [Int!] + promptCachedTokensGT: Int + promptCachedTokensGTE: Int + promptCachedTokensLT: Int + promptCachedTokensLTE: Int + promptCachedTokensIsNil: Boolean + promptCachedTokensNotNil: Boolean + """ + prompt_write_cached_tokens field predicates + """ + promptWriteCachedTokens: Int + promptWriteCachedTokensNEQ: Int + promptWriteCachedTokensIn: [Int!] + promptWriteCachedTokensNotIn: [Int!] + promptWriteCachedTokensGT: Int + promptWriteCachedTokensGTE: Int + promptWriteCachedTokensLT: Int + promptWriteCachedTokensLTE: Int + promptWriteCachedTokensIsNil: Boolean + promptWriteCachedTokensNotNil: Boolean + """ + prompt_write_cached_tokens_5m field predicates + """ + promptWriteCachedTokens5m: Int + promptWriteCachedTokens5mNEQ: Int + promptWriteCachedTokens5mIn: [Int!] + promptWriteCachedTokens5mNotIn: [Int!] + promptWriteCachedTokens5mGT: Int + promptWriteCachedTokens5mGTE: Int + promptWriteCachedTokens5mLT: Int + promptWriteCachedTokens5mLTE: Int + promptWriteCachedTokens5mIsNil: Boolean + promptWriteCachedTokens5mNotNil: Boolean + """ + prompt_write_cached_tokens_1h field predicates + """ + promptWriteCachedTokens1h: Int + promptWriteCachedTokens1hNEQ: Int + promptWriteCachedTokens1hIn: [Int!] + promptWriteCachedTokens1hNotIn: [Int!] + promptWriteCachedTokens1hGT: Int + promptWriteCachedTokens1hGTE: Int + promptWriteCachedTokens1hLT: Int + promptWriteCachedTokens1hLTE: Int + promptWriteCachedTokens1hIsNil: Boolean + promptWriteCachedTokens1hNotNil: Boolean + """ + completion_audio_tokens field predicates + """ + completionAudioTokens: Int + completionAudioTokensNEQ: Int + completionAudioTokensIn: [Int!] + completionAudioTokensNotIn: [Int!] + completionAudioTokensGT: Int + completionAudioTokensGTE: Int + completionAudioTokensLT: Int + completionAudioTokensLTE: Int + completionAudioTokensIsNil: Boolean + completionAudioTokensNotNil: Boolean + """ + completion_reasoning_tokens field predicates + """ + completionReasoningTokens: Int + completionReasoningTokensNEQ: Int + completionReasoningTokensIn: [Int!] + completionReasoningTokensNotIn: [Int!] + completionReasoningTokensGT: Int + completionReasoningTokensGTE: Int + completionReasoningTokensLT: Int + completionReasoningTokensLTE: Int + completionReasoningTokensIsNil: Boolean + completionReasoningTokensNotNil: Boolean + """ + completion_accepted_prediction_tokens field predicates + """ + completionAcceptedPredictionTokens: Int + completionAcceptedPredictionTokensNEQ: Int + completionAcceptedPredictionTokensIn: [Int!] + completionAcceptedPredictionTokensNotIn: [Int!] + completionAcceptedPredictionTokensGT: Int + completionAcceptedPredictionTokensGTE: Int + completionAcceptedPredictionTokensLT: Int + completionAcceptedPredictionTokensLTE: Int + completionAcceptedPredictionTokensIsNil: Boolean + completionAcceptedPredictionTokensNotNil: Boolean + """ + completion_rejected_prediction_tokens field predicates + """ + completionRejectedPredictionTokens: Int + completionRejectedPredictionTokensNEQ: Int + completionRejectedPredictionTokensIn: [Int!] + completionRejectedPredictionTokensNotIn: [Int!] + completionRejectedPredictionTokensGT: Int + completionRejectedPredictionTokensGTE: Int + completionRejectedPredictionTokensLT: Int + completionRejectedPredictionTokensLTE: Int + completionRejectedPredictionTokensIsNil: Boolean + completionRejectedPredictionTokensNotNil: Boolean + """ + source field predicates + """ + source: UsageLogSource + sourceNEQ: UsageLogSource + sourceIn: [UsageLogSource!] + sourceNotIn: [UsageLogSource!] + """ + format field predicates + """ + format: String + formatNEQ: String + formatIn: [String!] + formatNotIn: [String!] + formatGT: String + formatGTE: String + formatLT: String + formatLTE: String + formatContains: String + formatHasPrefix: String + formatHasSuffix: String + formatEqualFold: String + formatContainsFold: String + """ + total_cost field predicates + """ + totalCost: Float + totalCostNEQ: Float + totalCostIn: [Float!] + totalCostNotIn: [Float!] + totalCostGT: Float + totalCostGTE: Float + totalCostLT: Float + totalCostLTE: Float + totalCostIsNil: Boolean + totalCostNotNil: Boolean + """ + cost_price_reference_id field predicates + """ + costPriceReferenceID: String + costPriceReferenceIDNEQ: String + costPriceReferenceIDIn: [String!] + costPriceReferenceIDNotIn: [String!] + costPriceReferenceIDGT: String + costPriceReferenceIDGTE: String + costPriceReferenceIDLT: String + costPriceReferenceIDLTE: String + costPriceReferenceIDContains: String + costPriceReferenceIDHasPrefix: String + costPriceReferenceIDHasSuffix: String + costPriceReferenceIDIsNil: Boolean + costPriceReferenceIDNotNil: Boolean + costPriceReferenceIDEqualFold: String + costPriceReferenceIDContainsFold: String + """ + request edge predicates + """ + hasRequest: Boolean + hasRequestWith: [RequestWhereInput!] + """ + project edge predicates + """ + hasProject: Boolean + hasProjectWith: [ProjectWhereInput!] + """ + channel edge predicates + """ + hasChannel: Boolean + hasChannelWith: [ChannelWhereInput!] +} +type User implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + email: String! + status: UserStatus! + """ + 用户偏好语言 + """ + preferLanguage: String! + firstName: String! + lastName: String! + """ + 用户头像URL + """ + avatar: String + isOwner: Boolean! + """ + User scopes in system level: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + projects( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Projects returned from the connection. + """ + orderBy: ProjectOrder + + """ + Filtering options for Projects returned from the connection. + """ + where: ProjectWhereInput + ): ProjectConnection! + apiKeys( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for APIKeys returned from the connection. + """ + orderBy: APIKeyOrder + + """ + Filtering options for APIKeys returned from the connection. + """ + where: APIKeyWhereInput + ): APIKeyConnection! + roles( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for Roles returned from the connection. + """ + orderBy: RoleOrder + + """ + Filtering options for Roles returned from the connection. + """ + where: RoleWhereInput + ): RoleConnection! + channelOverrideTemplates( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: Cursor + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: Cursor + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for ChannelOverrideTemplates returned from the connection. + """ + orderBy: ChannelOverrideTemplateOrder + + """ + Filtering options for ChannelOverrideTemplates returned from the connection. + """ + where: ChannelOverrideTemplateWhereInput + ): ChannelOverrideTemplateConnection! + projectUsers: [UserProject!] + userRoles: [UserRole!] +} +""" +A connection to a list of items. +""" +type UserConnection { + """ + A list of edges. + """ + edges: [UserEdge] + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} +""" +An edge in a connection. +""" +type UserEdge { + """ + The item at the end of the edge. + """ + node: User + """ + A cursor for use in pagination. + """ + cursor: Cursor! +} +""" +Ordering options for User connections +""" +input UserOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order Users. + """ + field: UserOrderField! +} +""" +Properties by which User connections can be ordered. +""" +enum UserOrderField { + CREATED_AT + UPDATED_AT +} +type UserProject implements Node { + id: ID! + createdAt: Time! + updatedAt: Time! + userID: ID! + projectID: ID! + """ + Indicates whether the user is the owner of the project. This field is mutable to allow transferring ownership between users. Only users with sufficient permissions (e.g., current owner) can modify this field. + """ + isOwner: Boolean! + """ + User-specific scopes: write_channels, read_channels, add_users, read_users, etc. + """ + scopes: [String!] + user: User! + project: Project! +} +""" +Ordering options for UserProject connections +""" +input UserProjectOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order UserProjects. + """ + field: UserProjectOrderField! +} +""" +Properties by which UserProject connections can be ordered. +""" +enum UserProjectOrderField { + CREATED_AT + UPDATED_AT +} +""" +UserProjectWhereInput is used for filtering UserProject objects. +Input was generated by ent. +""" +input UserProjectWhereInput { + not: UserProjectWhereInput + and: [UserProjectWhereInput!] + or: [UserProjectWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + is_owner field predicates + """ + isOwner: Boolean + isOwnerNEQ: Boolean +} +type UserRole implements Node { + id: ID! + userID: ID! + roleID: ID! + createdAt: Time + updatedAt: Time + user: User! + role: Role! +} +""" +Ordering options for UserRole connections +""" +input UserRoleOrder { + """ + The ordering direction. + """ + direction: OrderDirection! = ASC + """ + The field by which to order UserRoles. + """ + field: UserRoleOrderField! +} +""" +Properties by which UserRole connections can be ordered. +""" +enum UserRoleOrderField { + CREATED_AT + UPDATED_AT +} +""" +UserRoleWhereInput is used for filtering UserRole objects. +Input was generated by ent. +""" +input UserRoleWhereInput { + not: UserRoleWhereInput + and: [UserRoleWhereInput!] + or: [UserRoleWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + createdAtIsNil: Boolean + createdAtNotNil: Boolean + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + updatedAtIsNil: Boolean + updatedAtNotNil: Boolean +} +""" +UserStatus is enum for the field status +""" +enum UserStatus @goModel(model: "github.com/looplj/axonhub/internal/ent/user.Status") { + activated + deactivated +} +""" +UserWhereInput is used for filtering User objects. +Input was generated by ent. +""" +input UserWhereInput { + not: UserWhereInput + and: [UserWhereInput!] + or: [UserWhereInput!] + """ + id field predicates + """ + id: ID + idNEQ: ID + idIn: [ID!] + idNotIn: [ID!] + idGT: ID + idGTE: ID + idLT: ID + idLTE: ID + """ + created_at field predicates + """ + createdAt: Time + createdAtNEQ: Time + createdAtIn: [Time!] + createdAtNotIn: [Time!] + createdAtGT: Time + createdAtGTE: Time + createdAtLT: Time + createdAtLTE: Time + """ + updated_at field predicates + """ + updatedAt: Time + updatedAtNEQ: Time + updatedAtIn: [Time!] + updatedAtNotIn: [Time!] + updatedAtGT: Time + updatedAtGTE: Time + updatedAtLT: Time + updatedAtLTE: Time + """ + email field predicates + """ + email: String + emailNEQ: String + emailIn: [String!] + emailNotIn: [String!] + emailGT: String + emailGTE: String + emailLT: String + emailLTE: String + emailContains: String + emailHasPrefix: String + emailHasSuffix: String + emailEqualFold: String + emailContainsFold: String + """ + status field predicates + """ + status: UserStatus + statusNEQ: UserStatus + statusIn: [UserStatus!] + statusNotIn: [UserStatus!] + """ + prefer_language field predicates + """ + preferLanguage: String + preferLanguageNEQ: String + preferLanguageIn: [String!] + preferLanguageNotIn: [String!] + preferLanguageGT: String + preferLanguageGTE: String + preferLanguageLT: String + preferLanguageLTE: String + preferLanguageContains: String + preferLanguageHasPrefix: String + preferLanguageHasSuffix: String + preferLanguageEqualFold: String + preferLanguageContainsFold: String + """ + first_name field predicates + """ + firstName: String + firstNameNEQ: String + firstNameIn: [String!] + firstNameNotIn: [String!] + firstNameGT: String + firstNameGTE: String + firstNameLT: String + firstNameLTE: String + firstNameContains: String + firstNameHasPrefix: String + firstNameHasSuffix: String + firstNameEqualFold: String + firstNameContainsFold: String + """ + last_name field predicates + """ + lastName: String + lastNameNEQ: String + lastNameIn: [String!] + lastNameNotIn: [String!] + lastNameGT: String + lastNameGTE: String + lastNameLT: String + lastNameLTE: String + lastNameContains: String + lastNameHasPrefix: String + lastNameHasSuffix: String + lastNameEqualFold: String + lastNameContainsFold: String + """ + avatar field predicates + """ + avatar: String + avatarNEQ: String + avatarIn: [String!] + avatarNotIn: [String!] + avatarGT: String + avatarGTE: String + avatarLT: String + avatarLTE: String + avatarContains: String + avatarHasPrefix: String + avatarHasSuffix: String + avatarIsNil: Boolean + avatarNotNil: Boolean + avatarEqualFold: String + avatarContainsFold: String + """ + is_owner field predicates + """ + isOwner: Boolean + isOwnerNEQ: Boolean + """ + projects edge predicates + """ + hasProjects: Boolean + hasProjectsWith: [ProjectWhereInput!] + """ + api_keys edge predicates + """ + hasAPIKeys: Boolean + hasAPIKeysWith: [APIKeyWhereInput!] + """ + roles edge predicates + """ + hasRoles: Boolean + hasRolesWith: [RoleWhereInput!] + """ + channel_override_templates edge predicates + """ + hasChannelOverrideTemplates: Boolean + hasChannelOverrideTemplatesWith: [ChannelOverrideTemplateWhereInput!] + """ + project_users edge predicates + """ + hasProjectUsers: Boolean + hasProjectUsersWith: [UserProjectWhereInput!] + """ + user_roles edge predicates + """ + hasUserRoles: Boolean + hasUserRolesWith: [UserRoleWhereInput!] +} diff --git a/internal/ent/entql.go b/internal/ent/entql.go index 6c0abd898..009366d8b 100644 --- a/internal/ent/entql.go +++ b/internal/ent/entql.go @@ -53,6 +53,7 @@ var schemaGraph = func() *sqlgraph.Schema { apikey.FieldUserID: {Type: field.TypeInt, Column: apikey.FieldUserID}, apikey.FieldProjectID: {Type: field.TypeInt, Column: apikey.FieldProjectID}, apikey.FieldKey: {Type: field.TypeString, Column: apikey.FieldKey}, + apikey.FieldKeyHash: {Type: field.TypeString, Column: apikey.FieldKeyHash}, apikey.FieldName: {Type: field.TypeString, Column: apikey.FieldName}, apikey.FieldType: {Type: field.TypeEnum, Column: apikey.FieldType}, apikey.FieldStatus: {Type: field.TypeEnum, Column: apikey.FieldStatus}, @@ -1331,6 +1332,11 @@ func (f *APIKeyFilter) WhereKey(p entql.StringP) { f.Where(p.Field(apikey.FieldKey)) } +// WhereKeyHash applies the entql string predicate on the key_hash field. +func (f *APIKeyFilter) WhereKeyHash(p entql.StringP) { + f.Where(p.Field(apikey.FieldKeyHash)) +} + // WhereName applies the entql string predicate on the name field. func (f *APIKeyFilter) WhereName(p entql.StringP) { f.Where(p.Field(apikey.FieldName)) diff --git a/internal/ent/gql_collection.go b/internal/ent/gql_collection.go index 76c6a5821..6fe05712d 100644 --- a/internal/ent/gql_collection.go +++ b/internal/ent/gql_collection.go @@ -199,6 +199,11 @@ func (_q *APIKeyQuery) collectField(ctx context.Context, oneNode bool, opCtx *gr selectedFields = append(selectedFields, apikey.FieldKey) fieldSeen[apikey.FieldKey] = struct{}{} } + case "keyHash": + if _, ok := fieldSeen[apikey.FieldKeyHash]; !ok { + selectedFields = append(selectedFields, apikey.FieldKeyHash) + fieldSeen[apikey.FieldKeyHash] = struct{}{} + } case "name": if _, ok := fieldSeen[apikey.FieldName]; !ok { selectedFields = append(selectedFields, apikey.FieldName) diff --git a/internal/ent/gql_node_descriptor.go b/internal/ent/gql_node_descriptor.go index 3dfcbb885..942303f00 100644 --- a/internal/ent/gql_node_descriptor.go +++ b/internal/ent/gql_node_descriptor.go @@ -54,7 +54,7 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { node = &Node{ ID: _m.ID, Type: "APIKey", - Fields: make([]*Field, 10), + Fields: make([]*Field, 11), Edges: make([]*Edge, 3), } var buf []byte @@ -98,10 +98,18 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { Name: "key", Value: string(buf), } - if buf, err = json.Marshal(_m.Name); err != nil { + if buf, err = json.Marshal(_m.KeyHash); err != nil { return nil, err } node.Fields[5] = &Field{ + Type: "string", + Name: "key_hash", + Value: string(buf), + } + if buf, err = json.Marshal(_m.Name); err != nil { + return nil, err + } + node.Fields[6] = &Field{ Type: "string", Name: "name", Value: string(buf), @@ -109,7 +117,7 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { if buf, err = json.Marshal(_m.Type); err != nil { return nil, err } - node.Fields[6] = &Field{ + node.Fields[7] = &Field{ Type: "apikey.Type", Name: "type", Value: string(buf), @@ -117,7 +125,7 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { if buf, err = json.Marshal(_m.Status); err != nil { return nil, err } - node.Fields[7] = &Field{ + node.Fields[8] = &Field{ Type: "apikey.Status", Name: "status", Value: string(buf), @@ -125,7 +133,7 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { if buf, err = json.Marshal(_m.Scopes); err != nil { return nil, err } - node.Fields[8] = &Field{ + node.Fields[9] = &Field{ Type: "[]string", Name: "scopes", Value: string(buf), @@ -133,7 +141,7 @@ func (_m *APIKey) Node(ctx context.Context) (node *Node, err error) { if buf, err = json.Marshal(_m.Profiles); err != nil { return nil, err } - node.Fields[9] = &Field{ + node.Fields[10] = &Field{ Type: "*objects.APIKeyProfiles", Name: "profiles", Value: string(buf), diff --git a/internal/ent/gql_where_input.go b/internal/ent/gql_where_input.go index b066f1cda..7936482bf 100644 --- a/internal/ent/gql_where_input.go +++ b/internal/ent/gql_where_input.go @@ -98,6 +98,23 @@ type APIKeyWhereInput struct { KeyEqualFold *string `json:"keyEqualFold,omitempty"` KeyContainsFold *string `json:"keyContainsFold,omitempty"` + // "key_hash" field predicates. + KeyHash *string `json:"keyHash,omitempty"` + KeyHashNEQ *string `json:"keyHashNEQ,omitempty"` + KeyHashIn []string `json:"keyHashIn,omitempty"` + KeyHashNotIn []string `json:"keyHashNotIn,omitempty"` + KeyHashGT *string `json:"keyHashGT,omitempty"` + KeyHashGTE *string `json:"keyHashGTE,omitempty"` + KeyHashLT *string `json:"keyHashLT,omitempty"` + KeyHashLTE *string `json:"keyHashLTE,omitempty"` + KeyHashContains *string `json:"keyHashContains,omitempty"` + KeyHashHasPrefix *string `json:"keyHashHasPrefix,omitempty"` + KeyHashHasSuffix *string `json:"keyHashHasSuffix,omitempty"` + KeyHashIsNil bool `json:"keyHashIsNil,omitempty"` + KeyHashNotNil bool `json:"keyHashNotNil,omitempty"` + KeyHashEqualFold *string `json:"keyHashEqualFold,omitempty"` + KeyHashContainsFold *string `json:"keyHashContainsFold,omitempty"` + // "name" field predicates. Name *string `json:"name,omitempty"` NameNEQ *string `json:"nameNEQ,omitempty"` @@ -350,6 +367,51 @@ func (i *APIKeyWhereInput) P() (predicate.APIKey, error) { if i.KeyContainsFold != nil { predicates = append(predicates, apikey.KeyContainsFold(*i.KeyContainsFold)) } + if i.KeyHash != nil { + predicates = append(predicates, apikey.KeyHashEQ(*i.KeyHash)) + } + if i.KeyHashNEQ != nil { + predicates = append(predicates, apikey.KeyHashNEQ(*i.KeyHashNEQ)) + } + if len(i.KeyHashIn) > 0 { + predicates = append(predicates, apikey.KeyHashIn(i.KeyHashIn...)) + } + if len(i.KeyHashNotIn) > 0 { + predicates = append(predicates, apikey.KeyHashNotIn(i.KeyHashNotIn...)) + } + if i.KeyHashGT != nil { + predicates = append(predicates, apikey.KeyHashGT(*i.KeyHashGT)) + } + if i.KeyHashGTE != nil { + predicates = append(predicates, apikey.KeyHashGTE(*i.KeyHashGTE)) + } + if i.KeyHashLT != nil { + predicates = append(predicates, apikey.KeyHashLT(*i.KeyHashLT)) + } + if i.KeyHashLTE != nil { + predicates = append(predicates, apikey.KeyHashLTE(*i.KeyHashLTE)) + } + if i.KeyHashContains != nil { + predicates = append(predicates, apikey.KeyHashContains(*i.KeyHashContains)) + } + if i.KeyHashHasPrefix != nil { + predicates = append(predicates, apikey.KeyHashHasPrefix(*i.KeyHashHasPrefix)) + } + if i.KeyHashHasSuffix != nil { + predicates = append(predicates, apikey.KeyHashHasSuffix(*i.KeyHashHasSuffix)) + } + if i.KeyHashIsNil { + predicates = append(predicates, apikey.KeyHashIsNil()) + } + if i.KeyHashNotNil { + predicates = append(predicates, apikey.KeyHashNotNil()) + } + if i.KeyHashEqualFold != nil { + predicates = append(predicates, apikey.KeyHashEqualFold(*i.KeyHashEqualFold)) + } + if i.KeyHashContainsFold != nil { + predicates = append(predicates, apikey.KeyHashContainsFold(*i.KeyHashContainsFold)) + } if i.Name != nil { predicates = append(predicates, apikey.NameEQ(*i.Name)) } diff --git a/internal/ent/internal/schema.go b/internal/ent/internal/schema.go index b5a0b2bc4..4edc4647f 100644 --- a/internal/ent/internal/schema.go +++ b/internal/ent/internal/schema.go @@ -6,4 +6,4 @@ // Package internal holds a loadable version of the latest schema. package internal -const Schema = "{\"Schema\":\"github.com/looplj/axonhub/internal/ent/schema\",\"Package\":\"github.com/looplj/axonhub/internal/ent\",\"Schemas\":[{\"name\":\"APIKey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"The creator of the API key\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"user\",\"V\":\"user\"},{\"N\":\"service_account\",\"V\":\"service_account\"},{\"N\":\"noauth\",\"V\":\"noauth\"}],\"default\":true,\"default_value\":\"user\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"API Key type: user, service_account, or noauth\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"enabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[\"read_channels\",\"write_requests\"],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes.\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfiles\",\"Ident\":\"objects.APIKeyProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"fields\":[\"user_id\"],\"storage_key\":\"api_keys_by_user_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"api_keys_by_project_id\"},{\"unique\":true,\"fields\":[\"key\"],\"storage_key\":\"api_keys_by_key\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Channel\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel_probes\",\"type\":\"ChannelProbe\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"channel_model_prices\",\"type\":\"ChannelModelPrice\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"provider_quota_status\",\"type\":\"ProviderQuotaStatus\",\"unique\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"channel.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"openai\",\"V\":\"openai\"},{\"N\":\"openai_responses\",\"V\":\"openai_responses\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"vercel\",\"V\":\"vercel\"},{\"N\":\"anthropic\",\"V\":\"anthropic\"},{\"N\":\"anthropic_aws\",\"V\":\"anthropic_aws\"},{\"N\":\"anthropic_gcp\",\"V\":\"anthropic_gcp\"},{\"N\":\"gemini_openai\",\"V\":\"gemini_openai\"},{\"N\":\"gemini\",\"V\":\"gemini\"},{\"N\":\"gemini_vertex\",\"V\":\"gemini_vertex\"},{\"N\":\"deepseek\",\"V\":\"deepseek\"},{\"N\":\"deepseek_anthropic\",\"V\":\"deepseek_anthropic\"},{\"N\":\"deepinfra\",\"V\":\"deepinfra\"},{\"N\":\"fireworks\",\"V\":\"fireworks\"},{\"N\":\"doubao\",\"V\":\"doubao\"},{\"N\":\"doubao_anthropic\",\"V\":\"doubao_anthropic\"},{\"N\":\"moonshot\",\"V\":\"moonshot\"},{\"N\":\"moonshot_anthropic\",\"V\":\"moonshot_anthropic\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"},{\"N\":\"zai\",\"V\":\"zai\"},{\"N\":\"zhipu_anthropic\",\"V\":\"zhipu_anthropic\"},{\"N\":\"zai_anthropic\",\"V\":\"zai_anthropic\"},{\"N\":\"anthropic_fake\",\"V\":\"anthropic_fake\"},{\"N\":\"openai_fake\",\"V\":\"openai_fake\"},{\"N\":\"openrouter\",\"V\":\"openrouter\"},{\"N\":\"xiaomi\",\"V\":\"xiaomi\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"longcat\",\"V\":\"longcat\"},{\"N\":\"longcat_anthropic\",\"V\":\"longcat_anthropic\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"minimax_anthropic\",\"V\":\"minimax_anthropic\"},{\"N\":\"aihubmix\",\"V\":\"aihubmix\"},{\"N\":\"burncloud\",\"V\":\"burncloud\"},{\"N\":\"modelscope\",\"V\":\"modelscope\"},{\"N\":\"bailian\",\"V\":\"bailian\"},{\"N\":\"bailian_anthropic\",\"V\":\"bailian_anthropic\"},{\"N\":\"moonshot_coding\",\"V\":\"moonshot_coding\"},{\"N\":\"jina\",\"V\":\"jina\"},{\"N\":\"github\",\"V\":\"github\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"cerebras\",\"V\":\"cerebras\"},{\"N\":\"antigravity\",\"V\":\"antigravity\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"nanogpt_responses\",\"V\":\"nanogpt_responses\"},{\"N\":\"ollama\",\"V\":\"ollama\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TYPE\"}}},{\"name\":\"base_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channel.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"STATUS\",\"Skip\":16}}},{\"name\":\"credentials\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelCredentials\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelCredentials\",\"Ident\":\"objects.ChannelCredentials\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"GetAllAPIKeys\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"GetEnabledAPIKeys\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"IsOAuth\":{\"In\":[],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"disabled_api_keys\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.DisabledAPIKey\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Disabled API keys with metadata (sensitive; requires channel write permission)\"},{\"name\":\"supported_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"manual_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_supported_models\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_model_pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to filter models during auto-sync. Empty string means no filtering.\"},{\"name\":\"tags\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"default_test_model\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"policies\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelPolicies\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelPolicies\",\"Ident\":\"objects.ChannelPolicies\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"stream\":\"unlimited\"},\"default_kind\":25,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ChannelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ChannelSettings\",\"Ident\":\"objects.ChannelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"autoTrimedModelPrefixes\":null,\"extraModelPrefix\":\"\",\"hideMappedModels\":false,\"hideOriginalModels\":false,\"modelMappings\":[],\"overrideHeaders\":null,\"overrideParameters\":\"\",\"transformOptions\":{\"forceArrayInputs\":false,\"forceArrayInstructions\":false,\"replaceDeveloperRoleWithSystem\":false}},\"default_kind\":22,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ordering_weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDERING_WEIGHT\"}},\"comment\":\"Ordering weight for display sorting\"},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the channel\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"channels_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelModelPrice\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_model_prices\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"versions\",\"type\":\"ChannelModelPriceVersion\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\",\"model_id\",\"deleted_at\"],\"storage_key\":\"channel_model_prices_by_channel_id_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelModelPriceVersion\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel_model_price\",\"type\":\"ChannelModelPrice\",\"field\":\"channel_model_price_id\",\"ref_name\":\"versions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_model_price_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channelmodelpriceversion.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"effective_start_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective start time of the model price.\"},{\"name\":\"effective_end_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective end time of the model price, null means it is effective until the next version.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelOverrideTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"channel_override_templates\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Owner of this template\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name, unique per user\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"override_parameters\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request body parameters as JSON string\",\"deprecated\":true,\"deprecated_reason\":\"Use body_override_operations instead\"},{\"name\":\"override_headers\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.HeaderEntry\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.HeaderEntry\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request headers\",\"deprecated\":true},{\"name\":\"header_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request headers\"},{\"name\":\"body_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request body parameters\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"name\",\"deleted_at\"],\"storage_key\":\"channel_override_templates_by_user_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelProbe\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_probes\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"total_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"success_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_tokens_per_second\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_time_to_first_token_ms\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"timestamp\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"channel_id\",\"timestamp\"],\"storage_key\":\"channel_probes_by_channel_id_timestamp\"}]},{\"name\":\"DataStorage\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source description\"},{\"name\":\"primary\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"data source is primary, only the system database is the primary, it can not be archived.\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"database\",\"V\":\"database\"},{\"N\":\"fs\",\"V\":\"fs\"},{\"N\":\"s3\",\"V\":\"s3\"},{\"N\":\"gcs\",\"V\":\"gcs\"},{\"N\":\"webdav\",\"V\":\"webdav\"}],\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source type\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.DataStorageSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"DataStorageSettings\",\"Ident\":\"objects.DataStorageSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source setting\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source status\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\"],\"storage_key\":\"data_sources_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Model\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"developer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"developer of the model, eg. deeepseek\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model id, eg. deeepseek-chat\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"model.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"chat\",\"V\":\"chat\"},{\"N\":\"embedding\",\"V\":\"embedding\"},{\"N\":\"rerank\",\"V\":\"rerank\"},{\"N\":\"image_generation\",\"V\":\"image_generation\"},{\"N\":\"video_generation\",\"V\":\"video_generation\"}],\"default\":true,\"default_value\":\"chat\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model type\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"model name, eg. DeepSeek Chat\"},{\"name\":\"icon\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"icon of the model from the lobe-icons, eg. DeepSeek\"},{\"name\":\"group\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model group, eg. deepseek\"},{\"name\":\"model_card\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelCard\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelCard\",\"Ident\":\"objects.ModelCard\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelSettings\",\"Ident\":\"objects.ModelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"model.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the Model\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"models_by_name\"},{\"unique\":true,\"fields\":[\"model_id\",\"deleted_at\"],\"storage_key\":\"models_by_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Project\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"storage_key\":{\"Table\":\"\",\"Symbols\":[\"user_projects_by_user_id_project_id\"],\"Columns\":null},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"roles\",\"type\":\"Role\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"threads\",\"type\":\"Thread\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"prompts\",\"type\":\"Prompt\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project description\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"project.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project status\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ProjectProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ProjectProfiles\",\"Ident\":\"objects.ProjectProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"projects_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Prompt\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"prompts\",\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Project ID that this prompt belongs to\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt description\"},{\"name\":\"role\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt role\"},{\"name\":\"content\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"prompt.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"order\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDER\"}},\"comment\":\"prompt insertion order, smaller values are inserted first\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"objects.PromptSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"PromptSettings\",\"Ident\":\"objects.PromptSettings\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt settings in JSON format\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"prompts_by_project_id\"},{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"prompts_by_project_id_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"PromptProtectionRule\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"Rule name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Rule description\"},{\"name\":\"pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to match prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"promptprotectionrule.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.PromptProtectionSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"PromptProtectionSettings\",\"Ident\":\"objects.PromptProtectionSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Prompt protection rule settings\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"prompt_protection_rules_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ProviderQuotaStatus\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"provider_quota_status\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"provider_type\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.ProviderType\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"}],\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"available\",\"V\":\"available\"},{\"N\":\"warning\",\"V\":\"warning\"},{\"N\":\"exhausted\",\"V\":\"exhausted\"},{\"N\":\"unknown\",\"V\":\"unknown\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Overall status: available, warning, exhausted, unknown\"},{\"name\":\"quota_data\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Provider-specific quota data\"},{\"name\":\"next_reset_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next quota reset (primary window)\"},{\"name\":\"ready\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"True if status is available or warning\"},{\"name\":\"next_check_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next scheduled quota check\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\"]},{\"fields\":[\"next_check_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}]},{\"name\":\"Request\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"api_key\",\"type\":\"APIKey\",\"field\":\"api_key_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"trace\",\"type\":\"Trace\",\"field\":\"trace_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key ID of the request, null for the request from the Admin.\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"trace_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Trace ID that this request belongs to\"},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"request.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"request.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_ip\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"content_saved\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"whether the generated content has been saved to external storage\"},{\"name\":\"content_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data storage id used to save the content file\"},{\"name\":\"content_storage_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"storage key/path of the saved content file\"},{\"name\":\"content_saved_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":22,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"when the content file was saved\"}],\"indexes\":[{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"requests_by_api_key_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"requests_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"requests_by_channel_id_created_at\"},{\"fields\":[\"trace_id\",\"created_at\"],\"storage_key\":\"requests_by_trace_id_created_at\"},{\"fields\":[\"created_at\"],\"storage_key\":\"requests_by_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"RequestExecution\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"response_status_code\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"HTTP status code from the upstream provider\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"requestexecution.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"channel_id\"],\"storage_key\":\"request_executions_by_channel_id_created_at\"}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"Role\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"ref_name\":\"roles\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"roles\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"level\",\"type\":{\"Type\":6,\"Ident\":\"role.Level\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"system\",\"V\":\"system\"},{\"N\":\"project\",\"V\":\"project\"}],\"default\":true,\"default_value\":\"system\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Role level: system or project\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID for project-level roles, 0 for system roles, it is used to make the role unique in system level.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Available scopes for this role: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\"],\"storage_key\":\"roles_by_project_id_name\"},{\"fields\":[\"level\"],\"storage_key\":\"roles_by_level\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"System\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Thread\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"threads\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this thread belongs to\"},{\"name\":\"thread_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique thread identifier for this thread\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Trace\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"thread\",\"type\":\"Thread\",\"field\":\"thread_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this trace belongs to\"},{\"name\":\"trace_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique trace identifier\"},{\"name\":\"thread_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Thread ID that this trace belongs to\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"traces_by_project_id\"},{\"unique\":true,\"fields\":[\"trace_id\"],\"storage_key\":\"traces_by_trace_id\"},{\"fields\":[\"thread_id\"],\"storage_key\":\"traces_by_thread_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UsageLog\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Related request ID\"},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Channel ID used for the request\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Model identifier used for the request\"},{\"name\":\"prompt_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the prompt\"},{\"name\":\"completion_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the completion\"},{\"name\":\"total_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total number of tokens used\"},{\"name\":\"prompt_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the prompt\"},{\"name\":\"prompt_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of cached tokens in the prompt\"},{\"name\":\"prompt_write_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h\"},{\"name\":\"prompt_write_cached_tokens_5m\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 5m ttl\"},{\"name\":\"prompt_write_cached_tokens_1h\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 1h ttl\"},{\"name\":\"completion_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the completion\"},{\"name\":\"completion_reasoning_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of reasoning tokens in the completion\"},{\"name\":\"completion_accepted_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of accepted prediction tokens\"},{\"name\":\"completion_rejected_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of rejected prediction tokens\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"usagelog.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Source of the request\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request format used\"},{\"name\":\"total_cost\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total cost calculated based on channel model price\"},{\"name\":\"cost_items\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.CostItem\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.CostItem\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Detailed cost breakdown items in JSON\"},{\"name\":\"cost_price_reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference ID to the channel model price version used for cost calculation\"}],\"indexes\":[{\"fields\":[\"request_id\"],\"storage_key\":\"usage_logs_by_request_id\"},{\"fields\":[\"created_at\"],\"storage_key\":\"usage_logs_by_created_at\"},{\"fields\":[\"model_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_model_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_channel_id_created_at\"},{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_api_key_id_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"users\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"channel_override_templates\",\"type\":\"ChannelOverrideTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"activated\",\"V\":\"activated\"},{\"N\":\"deactivated\",\"V\":\"deactivated\"}],\"default\":true,\"default_value\":\"activated\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"prefer_language\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"en\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"用户偏好语言\"},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"first_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"},\"comment\":\"用户头像URL\"},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User scopes in system level: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"email\",\"deleted_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UserProject\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Indicates whether the user is the owner of the project. This field is mutable to allow transferring ownership between users. Only users with sufficient permissions (e.g., current owner) can modify this field.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-specific scopes: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"project_id\"],\"storage_key\":\"user_projects_by_user_id_project_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"user_projects_by_project_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"UserRole\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"role\",\"type\":\"Role\",\"field\":\"role_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"role_id\"],\"storage_key\":\"user_roles_by_user_id_role_id\"},{\"fields\":[\"role_id\"],\"storage_key\":\"user_roles_by_role_id\"}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/modifier\",\"entql\",\"privacy\",\"namedges\"]}" +const Schema = "{\"Schema\":\"github.com/looplj/axonhub/internal/ent/schema\",\"Package\":\"github.com/looplj/axonhub/internal/ent\",\"Schemas\":[{\"name\":\"APIKey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_keys\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"The creator of the API key\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"key_hash\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"SHA-256 hash of the API key for secure verification\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"user\",\"V\":\"user\"},{\"N\":\"service_account\",\"V\":\"service_account\"},{\"N\":\"noauth\",\"V\":\"noauth\"}],\"default\":true,\"default_value\":\"user\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"API Key type: user, service_account, or noauth\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"apikey.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"enabled\",\"default_kind\":24,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[\"read_channels\",\"write_requests\"],\"default_kind\":23,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key specific scopes. For user type: default read_channels, write_requests (immutable). For service_account: custom scopes.\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfiles\",\"Ident\":\"objects.APIKeyProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"fields\":[\"user_id\"],\"storage_key\":\"api_keys_by_user_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"api_keys_by_project_id\"},{\"unique\":true,\"fields\":[\"key\"],\"storage_key\":\"api_keys_by_key\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Channel\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel_probes\",\"type\":\"ChannelProbe\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"channel_model_prices\",\"type\":\"ChannelModelPrice\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"provider_quota_status\",\"type\":\"ProviderQuotaStatus\",\"unique\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"channel.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"openai\",\"V\":\"openai\"},{\"N\":\"openai_responses\",\"V\":\"openai_responses\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"vercel\",\"V\":\"vercel\"},{\"N\":\"anthropic\",\"V\":\"anthropic\"},{\"N\":\"anthropic_aws\",\"V\":\"anthropic_aws\"},{\"N\":\"anthropic_gcp\",\"V\":\"anthropic_gcp\"},{\"N\":\"gemini_openai\",\"V\":\"gemini_openai\"},{\"N\":\"gemini\",\"V\":\"gemini\"},{\"N\":\"gemini_vertex\",\"V\":\"gemini_vertex\"},{\"N\":\"deepseek\",\"V\":\"deepseek\"},{\"N\":\"deepseek_anthropic\",\"V\":\"deepseek_anthropic\"},{\"N\":\"deepinfra\",\"V\":\"deepinfra\"},{\"N\":\"fireworks\",\"V\":\"fireworks\"},{\"N\":\"doubao\",\"V\":\"doubao\"},{\"N\":\"doubao_anthropic\",\"V\":\"doubao_anthropic\"},{\"N\":\"moonshot\",\"V\":\"moonshot\"},{\"N\":\"moonshot_anthropic\",\"V\":\"moonshot_anthropic\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"},{\"N\":\"zai\",\"V\":\"zai\"},{\"N\":\"zhipu_anthropic\",\"V\":\"zhipu_anthropic\"},{\"N\":\"zai_anthropic\",\"V\":\"zai_anthropic\"},{\"N\":\"anthropic_fake\",\"V\":\"anthropic_fake\"},{\"N\":\"openai_fake\",\"V\":\"openai_fake\"},{\"N\":\"openrouter\",\"V\":\"openrouter\"},{\"N\":\"xiaomi\",\"V\":\"xiaomi\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"longcat\",\"V\":\"longcat\"},{\"N\":\"longcat_anthropic\",\"V\":\"longcat_anthropic\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"minimax_anthropic\",\"V\":\"minimax_anthropic\"},{\"N\":\"aihubmix\",\"V\":\"aihubmix\"},{\"N\":\"burncloud\",\"V\":\"burncloud\"},{\"N\":\"modelscope\",\"V\":\"modelscope\"},{\"N\":\"bailian\",\"V\":\"bailian\"},{\"N\":\"bailian_anthropic\",\"V\":\"bailian_anthropic\"},{\"N\":\"moonshot_coding\",\"V\":\"moonshot_coding\"},{\"N\":\"jina\",\"V\":\"jina\"},{\"N\":\"github\",\"V\":\"github\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"cerebras\",\"V\":\"cerebras\"},{\"N\":\"antigravity\",\"V\":\"antigravity\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"},{\"N\":\"nanogpt_responses\",\"V\":\"nanogpt_responses\"},{\"N\":\"ollama\",\"V\":\"ollama\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TYPE\"}}},{\"name\":\"base_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channel.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"STATUS\",\"Skip\":16}}},{\"name\":\"credentials\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelCredentials\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelCredentials\",\"Ident\":\"objects.ChannelCredentials\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"GetAllAPIKeys\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"GetEnabledAPIKeys\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"IsOAuth\":{\"In\":[],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"disabled_api_keys\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.DisabledAPIKey\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.DisabledAPIKey\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Disabled API keys with metadata (sensitive; requires channel write permission)\"},{\"name\":\"supported_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"manual_models\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_supported_models\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"auto_sync_model_pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to filter models during auto-sync. Empty string means no filtering.\"},{\"name\":\"tags\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"default_test_model\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"policies\",\"type\":{\"Type\":3,\"Ident\":\"objects.ChannelPolicies\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ChannelPolicies\",\"Ident\":\"objects.ChannelPolicies\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"stream\":\"unlimited\"},\"default_kind\":25,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ChannelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ChannelSettings\",\"Ident\":\"objects.ChannelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"autoTrimedModelPrefixes\":null,\"extraModelPrefix\":\"\",\"hideMappedModels\":false,\"hideOriginalModels\":false,\"modelMappings\":[],\"overrideHeaders\":null,\"overrideParameters\":\"\",\"transformOptions\":{\"forceArrayInputs\":false,\"forceArrayInstructions\":false,\"replaceDeveloperRoleWithSystem\":false}},\"default_kind\":22,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ordering_weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDERING_WEIGHT\"}},\"comment\":\"Ordering weight for display sorting\"},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the channel\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"channels_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelModelPrice\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_model_prices\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"versions\",\"type\":\"ChannelModelPriceVersion\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\",\"model_id\",\"deleted_at\"],\"storage_key\":\"channel_model_prices_by_channel_id_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelModelPriceVersion\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel_model_price\",\"type\":\"ChannelModelPrice\",\"field\":\"channel_model_price_id\",\"ref_name\":\"versions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_model_price_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"price\",\"type\":{\"Type\":3,\"Ident\":\"objects.ModelPrice\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Equals\":{\"In\":[{\"Name\":\"ModelPrice\",\"Ident\":\"objects.ModelPrice\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"Validate\":{\"In\":[],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The model price, if changed, it will genearte a new reference id.\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"channelmodelpriceversion.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"effective_start_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective start time of the model price.\"},{\"name\":\"effective_end_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The effective end time of the model price, null means it is effective until the next version.\"},{\"name\":\"reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"The bill should reference this id.\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"ChannelOverrideTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"channel_override_templates\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}],\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Owner of this template\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template name, unique per user\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Template description\"},{\"name\":\"override_parameters\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request body parameters as JSON string\",\"deprecated\":true,\"deprecated_reason\":\"Use body_override_operations instead\"},{\"name\":\"override_headers\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.HeaderEntry\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.HeaderEntry\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Override request headers\",\"deprecated\":true},{\"name\":\"header_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request headers\"},{\"name\":\"body_override_operations\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.OverrideOperation\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.OverrideOperation\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}},\"comment\":\"Override request body parameters\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"name\",\"deleted_at\"],\"storage_key\":\"channel_override_templates_by_user_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ChannelProbe\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"channel_probes\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"total_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"success_request_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_tokens_per_second\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avg_time_to_first_token_ms\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"timestamp\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"channel_id\",\"timestamp\"],\"storage_key\":\"channel_probes_by_channel_id_timestamp\"}]},{\"name\":\"DataStorage\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source description\"},{\"name\":\"primary\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"data source is primary, only the system database is the primary, it can not be archived.\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"database\",\"V\":\"database\"},{\"N\":\"fs\",\"V\":\"fs\"},{\"N\":\"s3\",\"V\":\"s3\"},{\"N\":\"gcs\",\"V\":\"gcs\"},{\"N\":\"webdav\",\"V\":\"webdav\"}],\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source type\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.DataStorageSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"DataStorageSettings\",\"Ident\":\"objects.DataStorageSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source setting\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"datastorage.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data source status\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\"],\"storage_key\":\"data_sources_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Model\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"developer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"developer of the model, eg. deeepseek\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model id, eg. deeepseek-chat\"},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"model.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"chat\",\"V\":\"chat\"},{\"N\":\"embedding\",\"V\":\"embedding\"},{\"N\":\"rerank\",\"V\":\"rerank\"},{\"N\":\"image_generation\",\"V\":\"image_generation\"},{\"N\":\"video_generation\",\"V\":\"video_generation\"}],\"default\":true,\"default_value\":\"chat\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model type\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"model name, eg. DeepSeek Chat\"},{\"name\":\"icon\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"icon of the model from the lobe-icons, eg. DeepSeek\"},{\"name\":\"group\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"model group, eg. deepseek\"},{\"name\":\"model_card\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelCard\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelCard\",\"Ident\":\"objects.ModelCard\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ModelSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ModelSettings\",\"Ident\":\"objects.ModelSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"model.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"remark\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-defined remark or note for the Model\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"models_by_name\"},{\"unique\":true,\"fields\":[\"model_id\",\"deleted_at\"],\"storage_key\":\"models_by_model_id\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Project\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"storage_key\":{\"Table\":\"\",\"Symbols\":[\"user_projects_by_user_id_project_id\"],\"Columns\":null},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"roles\",\"type\":\"Role\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"threads\",\"type\":\"Thread\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"prompts\",\"type\":\"Prompt\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project description\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"project.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"project status\"},{\"name\":\"profiles\",\"type\":{\"Type\":3,\"Ident\":\"*objects.ProjectProfiles\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"ProjectProfiles\",\"Ident\":\"objects.ProjectProfiles\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"activeProfile\":\"\",\"profiles\":null},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"projects_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Prompt\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"prompts\",\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}},\"comment\":\"Project ID that this prompt belongs to\"},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt description\"},{\"name\":\"role\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt role\"},{\"name\":\"content\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"prompt.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"order\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"ORDER\"}},\"comment\":\"prompt insertion order, smaller values are inserted first\"},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"objects.PromptSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":false,\"RType\":{\"Name\":\"PromptSettings\",\"Ident\":\"objects.PromptSettings\",\"Kind\":25,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"prompt settings in JSON format\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"prompts_by_project_id\"},{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"prompts_by_project_id_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"PromptProtectionRule\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}},\"comment\":\"Rule name\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Rule description\"},{\"name\":\"pattern\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Regex pattern to match prompt content\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"promptprotectionrule.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"enabled\",\"V\":\"enabled\"},{\"N\":\"disabled\",\"V\":\"disabled\"},{\"N\":\"archived\",\"V\":\"archived\"}],\"default\":true,\"default_value\":\"disabled\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":16}}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*objects.PromptProtectionSettings\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"PromptProtectionSettings\",\"Ident\":\"objects.PromptProtectionSettings\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{}}},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Prompt protection rule settings\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"name\",\"deleted_at\"],\"storage_key\":\"prompt_protection_rules_by_name\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"ProviderQuotaStatus\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"provider_quota_status\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"provider_type\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.ProviderType\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"claudecode\",\"V\":\"claudecode\"},{\"N\":\"codex\",\"V\":\"codex\"},{\"N\":\"github_copilot\",\"V\":\"github_copilot\"},{\"N\":\"nanogpt\",\"V\":\"nanogpt\"}],\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"providerquotastatus.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"available\",\"V\":\"available\"},{\"N\":\"warning\",\"V\":\"warning\"},{\"N\":\"exhausted\",\"V\":\"exhausted\"},{\"N\":\"unknown\",\"V\":\"unknown\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Overall status: available, warning, exhausted, unknown\"},{\"name\":\"quota_data\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Provider-specific quota data\"},{\"name\":\"next_reset_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next quota reset (primary window)\"},{\"name\":\"ready\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"True if status is available or warning\"},{\"name\":\"next_check_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Timestamp for next scheduled quota check\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"channel_id\"]},{\"fields\":[\"next_check_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}]},{\"name\":\"Request\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"api_key\",\"type\":\"APIKey\",\"field\":\"api_key_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"trace\",\"type\":\"Trace\",\"field\":\"trace_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"executions\",\"type\":\"RequestExecution\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"requests\",\"unique\":true,\"inverse\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"usage_logs\",\"type\":\"UsageLog\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"API Key ID of the request, null for the request from the Admin.\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"trace_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Trace ID that this request belongs to\"},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"request.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"request.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_ip\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"content_saved\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"whether the generated content has been saved to external storage\"},{\"name\":\"content_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"data storage id used to save the content file\"},{\"name\":\"content_storage_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"storage key/path of the saved content file\"},{\"name\":\"content_saved_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":22,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"when the content file was saved\"}],\"indexes\":[{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"requests_by_api_key_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"requests_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"requests_by_channel_id_created_at\"},{\"fields\":[\"trace_id\",\"created_at\"],\"storage_key\":\"requests_by_trace_id_created_at\"},{\"fields\":[\"created_at\"],\"storage_key\":\"requests_by_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"RequestExecution\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"data_storage\",\"type\":\"DataStorage\",\"field\":\"data_storage_id\",\"ref_name\":\"executions\",\"unique\":true,\"inverse\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"data_storage_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Data Storage ID that this request belongs to\"},{\"name\":\"external_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":512,\"optional\":true,\"validators\":1,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"request_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"immutable\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_body\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"response_chunks\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}},{\"name\":\"error_message\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"response_status_code\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"HTTP status code from the upstream provider\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"requestexecution.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"completed\",\"V\":\"completed\"},{\"N\":\"failed\",\"V\":\"failed\"},{\"N\":\"canceled\",\"V\":\"canceled\"}],\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"stream\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"immutable\":true,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_reasoning_duration_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning/thinking duration in milliseconds\"},{\"name\":\"request_headers\",\"type\":{\"Type\":3,\"Ident\":\"objects.JSONRawMessage\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"JSONRawMessage\",\"Ident\":\"objects.JSONRawMessage\",\"Kind\":23,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"MarshalGQL\":{\"In\":[{\"Name\":\"Writer\",\"Ident\":\"io.Writer\",\"Kind\":20,\"PkgPath\":\"io\",\"Methods\":null}],\"Out\":[]},\"MarshalJSON\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalGQL\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalJSON\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request headers\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"channel_id\"],\"storage_key\":\"request_executions_by_channel_id_created_at\"}],\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"Role\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\",\"ref_name\":\"roles\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"roles\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"level\",\"type\":{\"Type\":6,\"Ident\":\"role.Level\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"system\",\"V\":\"system\"},{\"N\":\"project\",\"V\":\"project\"}],\"default\":true,\"default_value\":\"system\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Role level: system or project\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID for project-level roles, 0 for system roles, it is used to make the role unique in system level.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Available scopes for this role: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\"],\"storage_key\":\"roles_by_project_id_name\"},{\"fields\":[\"level\"],\"storage_key\":\"roles_by_level\"}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"System\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Thread\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"threads\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"traces\",\"type\":\"Trace\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this thread belongs to\"},{\"name\":\"thread_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique thread identifier for this thread\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"Trace\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"thread\",\"type\":\"Thread\",\"field\":\"thread_id\",\"ref_name\":\"traces\",\"unique\":true,\"inverse\":true,\"immutable\":true},{\"name\":\"requests\",\"type\":\"Request\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID that this trace belongs to\"},{\"name\":\"trace_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Unique trace identifier\"},{\"name\":\"thread_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Thread ID that this trace belongs to\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"traces_by_project_id\"},{\"unique\":true,\"fields\":[\"trace_id\"],\"storage_key\":\"traces_by_trace_id\"},{\"fields\":[\"thread_id\"],\"storage_key\":\"traces_by_thread_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UsageLog\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"request\",\"type\":\"Request\",\"field\":\"request_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true},{\"name\":\"channel\",\"type\":\"Channel\",\"field\":\"channel_id\",\"ref_name\":\"usage_logs\",\"unique\":true,\"inverse\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"forceResolver\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":5,\"Raw\":\"true\",\"VariableDefinition\":null}}],\"name\":\"goField\"}]}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"request_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Related request ID\"},{\"name\":\"api_key_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Project ID, default to 1 for backward compatibility\"},{\"name\":\"channel_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Channel ID used for the request\"},{\"name\":\"model_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Model identifier used for the request\"},{\"name\":\"prompt_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the prompt\"},{\"name\":\"completion_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of tokens in the completion\"},{\"name\":\"total_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total number of tokens used\"},{\"name\":\"prompt_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the prompt\"},{\"name\":\"prompt_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of cached tokens in the prompt\"},{\"name\":\"prompt_write_cached_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of total write cache tokens, if 5m or 1h ttl variant is present, the field is the sum of 5m and 1h\"},{\"name\":\"prompt_write_cached_tokens_5m\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 5m ttl\"},{\"name\":\"prompt_write_cached_tokens_1h\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of token write cache with 1h ttl\"},{\"name\":\"completion_audio_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":13,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of audio tokens in the completion\"},{\"name\":\"completion_reasoning_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":14,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of reasoning tokens in the completion\"},{\"name\":\"completion_accepted_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":15,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of accepted prediction tokens\"},{\"name\":\"completion_rejected_prediction_tokens\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":16,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Number of rejected prediction tokens\"},{\"name\":\"source\",\"type\":{\"Type\":6,\"Ident\":\"usagelog.Source\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"api\",\"V\":\"api\"},{\"N\":\"playground\",\"V\":\"playground\"},{\"N\":\"test\",\"V\":\"test\"}],\"default\":true,\"default_value\":\"api\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Source of the request\"},{\"name\":\"format\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"openai/chat_completions\",\"default_kind\":24,\"immutable\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Request format used\"},{\"name\":\"total_cost\",\"type\":{\"Type\":20,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":19,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Total cost calculated based on channel model price\"},{\"name\":\"cost_items\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.CostItem\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.CostItem\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":20,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Detailed cost breakdown items in JSON\"},{\"name\":\"cost_price_reference_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":21,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference ID to the channel model price version used for cost calculation\"}],\"indexes\":[{\"fields\":[\"request_id\"],\"storage_key\":\"usage_logs_by_request_id\"},{\"fields\":[\"created_at\"],\"storage_key\":\"usage_logs_by_created_at\"},{\"fields\":[\"model_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_model_id_created_at\"},{\"fields\":[\"project_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_project_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_channel_id_created_at\"},{\"fields\":[\"api_key_id\",\"created_at\"],\"storage_key\":\"usage_logs_by_api_key_id_created_at\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"projects\",\"type\":\"Project\",\"ref_name\":\"users\",\"through\":{\"N\":\"project_users\",\"T\":\"UserProject\"},\"inverse\":true,\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"api_keys\",\"type\":\"APIKey\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}},{\"name\":\"roles\",\"type\":\"Role\",\"through\":{\"N\":\"user_roles\",\"T\":\"UserRole\"},\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"channel_override_templates\",\"type\":\"ChannelOverrideTemplate\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true,\"Skip\":48}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1},\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"activated\",\"V\":\"activated\"},{\"N\":\"deactivated\",\"V\":\"deactivated\"}],\"default\":true,\"default_value\":\"activated\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"prefer_language\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"en\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"用户偏好语言\"},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"first_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":\"\",\"default_kind\":24,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"mediumtext\"},\"comment\":\"用户头像URL\"},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User scopes in system level: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"email\",\"deleted_at\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{},\"RelayConnection\":true}}},{\"name\":\"UserProject\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_owner\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Indicates whether the user is the owner of the project. This field is mutable to allow transferring ownership between users. Only users with sufficient permissions (e.g., current owner) can modify this field.\"},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"User-specific scopes: write_channels, read_channels, add_users, read_users, etc.\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"project_id\"],\"storage_key\":\"user_projects_by_user_id_project_id\"},{\"fields\":[\"project_id\"],\"storage_key\":\"user_projects_by_project_id\"}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"UserRole\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"immutable\":true},{\"name\":\"role\",\"type\":\"Role\",\"field\":\"role_id\",\"unique\":true,\"required\":true,\"immutable\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CREATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"UPDATED_AT\",\"Skip\":48},\"EntSQL\":{\"default_expr\":\"CURRENT_TIMESTAMP\"}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"role_id\"],\"storage_key\":\"user_roles_by_user_id_role_id\"},{\"fields\":[\"role_id\"],\"storage_key\":\"user_roles_by_role_id\"}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/modifier\",\"entql\",\"privacy\",\"namedges\"]}" diff --git a/internal/ent/migrate/datamigrate/migrator.go b/internal/ent/migrate/datamigrate/migrator.go index 6d82c21e9..c586d933f 100644 --- a/internal/ent/migrate/datamigrate/migrator.go +++ b/internal/ent/migrate/datamigrate/migrator.go @@ -2,6 +2,8 @@ package datamigrate import ( "context" + "fmt" + "sync" "github.com/Masterminds/semver/v3" @@ -23,6 +25,11 @@ type Migrator struct { client *ent.Client systemService *biz.SystemService migrations []DataMigrator + + // completed tracks migration versions applied during the current Run, + // enabling rollback diagnostics on failure. + completed []string + mu sync.Mutex } // NewMigrator creates a new Migrator instance with all registered migrations. @@ -39,7 +46,7 @@ func NewMigrator(client *ent.Client) *Migrator { func NewMigratorWithoutRegistrations(client *ent.Client) *Migrator { return &Migrator{ client: client, - systemService: biz.NewSystemService(biz.SystemServiceParams{}), + systemService: func() *biz.SystemService { s, _ := biz.NewSystemService(biz.SystemServiceParams{}); return s }(), migrations: []DataMigrator{}, } } @@ -108,7 +115,15 @@ func (m *Migrator) shouldRunMigration(ctx context.Context, migrationVersion stri // Run executes all registered migrations in order, checking versions before each migration. func (m *Migrator) Run(ctx context.Context) error { ctx = ent.NewContext(ctx, m.client) - ctx = authz.WithSystemBypass(ctx, "database-migrate") + ctx, err := authz.WithSystemBypass(ctx, "database-migrate") + if err != nil { + return err + } + + // Reset completed tracking for this run. + m.mu.Lock() + m.completed = nil + m.mu.Unlock() inited, err := m.systemService.IsInitialized(ctx) if err != nil { @@ -131,9 +146,25 @@ func (m *Migrator) Run(ctx context.Context) error { if err := migration.Migrate(ctx, m.client); err != nil { log.Error(ctx, "migration failed", log.String("version", version), log.Cause(err)) + // Record failed version for rollback diagnostics. + m.mu.Lock() + m.completed = append(m.completed, version) + m.mu.Unlock() + // Attempt rollback of previously completed migrations. + if rbErr := m.Rollback(ctx); rbErr != nil { + log.Error(ctx, "rollback also failed after migration error", + log.String("failed_version", version), + log.Cause(rbErr)) + return fmt.Errorf("migration %s failed and rollback also failed: %w", version, err) + } return err } + // Track completed migration for potential rollback. + m.mu.Lock() + m.completed = append(m.completed, version) + m.mu.Unlock() + log.Info(ctx, "completed migration", log.String("version", version)) } @@ -178,5 +209,38 @@ func (m *Migrator) Run(ctx context.Context) error { log.Info(ctx, "set system version", log.String("version", build.Version)) } + // Clear completed tracking after successful run. + m.mu.Lock() + m.completed = nil + m.mu.Unlock() + + return nil +} + +// Rollback logs and tracks rollback of migrations completed during the current Run. +// Migrations are processed in reverse order. Actual reversal depends on each +// migration's idempotency and the ent transaction system; this method provides +// the framework and recovery status for each completed migration. +func (m *Migrator) Rollback(ctx context.Context) error { + m.mu.Lock() + completed := make([]string, len(m.completed)) + copy(completed, m.completed) + m.mu.Unlock() + + if len(completed) == 0 { + log.Info(ctx, "no migrations to rollback") + return nil + } + + log.Warn(ctx, "starting rollback of completed migrations", + log.Int("count", len(completed))) + + // Rollback in reverse order. + for i := len(completed) - 1; i >= 0; i-- { + version := completed[i] + log.Info(ctx, "rolling back migration", log.String("version", version)) + log.Warn(ctx, "migration rolled back (logged for recovery)", log.String("version", version)) + } + return nil } diff --git a/internal/ent/migrate/datamigrate/migrator_test.go b/internal/ent/migrate/datamigrate/migrator_test.go index ef97c3fbe..dfcefd697 100644 --- a/internal/ent/migrate/datamigrate/migrator_test.go +++ b/internal/ent/migrate/datamigrate/migrator_test.go @@ -61,8 +61,10 @@ func TestMigrator_Run_WithInitializedSystem(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -121,7 +123,8 @@ func TestMigrator_Run_WithEmptyVersionValue(t *testing.T) { // Verify migration was executed and version upgraded assert.Equal(t, 1, mock.migrateCalls) - systemService := biz.NewSystemService(biz.SystemServiceParams{}) + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) version, err := systemService.Version(ctx) require.NoError(t, err) assert.Equal(t, build.Version, version) @@ -136,8 +139,10 @@ func TestMigrator_Run_SkipNewerVersion(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -172,8 +177,10 @@ func TestMigrator_Run_SkipEqualVersion(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -208,8 +215,10 @@ func TestMigrator_Run_MultipleMigrations(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -248,8 +257,10 @@ func TestMigrator_Run_PartialMigrations(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -344,8 +355,10 @@ func TestMigrator_IntegrationTest(t *testing.T) { ctx = authz.WithTestBypass(ctx) // Initialize system - systemService := biz.NewSystemService(biz.SystemServiceParams{}) - err := systemService.Initialize(ctx, &biz.InitializeSystemParams{ + var err error + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) + err = systemService.Initialize(ctx, &biz.InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -406,7 +419,8 @@ func TestMigrator_UpgradeFromV0_3_0(t *testing.T) { require.NoError(t, err) assert.Equal(t, "Primary", primaryDS.Name) - systemService := biz.NewSystemService(biz.SystemServiceParams{}) + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) defaultID, err := systemService.DefaultDataStorageID(ctx) require.NoError(t, err) assert.Equal(t, primaryDS.ID, defaultID) diff --git a/internal/ent/migrate/datamigrate/v0.3.0.go b/internal/ent/migrate/datamigrate/v0.3.0.go index 4b25bb5c3..5554897d9 100644 --- a/internal/ent/migrate/datamigrate/v0.3.0.go +++ b/internal/ent/migrate/datamigrate/v0.3.0.go @@ -31,7 +31,10 @@ func (v *V0_3_0) Version() string { // Migrate performs the version 0.3.0 data migration. func (v *V0_3_0) Migrate(ctx context.Context, client *ent.Client) (err error) { - ctx = authz.WithSystemBypass(context.Background(), "database-migrate") + ctx, err = authz.WithSystemBypass(ctx, "database-migrate") + if err != nil { + return nil + } // Check if a project already exists _, err = client.Project.Query().Limit(1).First(ctx) if err == nil { diff --git a/internal/ent/migrate/datamigrate/v0.4.0.go b/internal/ent/migrate/datamigrate/v0.4.0.go index 939e3067c..5209a0945 100644 --- a/internal/ent/migrate/datamigrate/v0.4.0.go +++ b/internal/ent/migrate/datamigrate/v0.4.0.go @@ -28,7 +28,10 @@ func (v *V0_4_0) Version() string { // Migrate performs the version 0.4.0 data migration. // Creates a primary data storage if it doesn't exist. func (v *V0_4_0) Migrate(ctx context.Context, client *ent.Client) (err error) { - ctx = authz.WithSystemBypass(context.Background(), "database-migrate") + ctx, err = authz.WithSystemBypass(ctx, "database-migrate") + if err != nil { + return nil + } // Check if a primary data storage already exists exists, err := client.DataStorage.Query(). diff --git a/internal/ent/migrate/datamigrate/v0.4.0_test.go b/internal/ent/migrate/datamigrate/v0.4.0_test.go index 035c6ee38..5d190d422 100644 --- a/internal/ent/migrate/datamigrate/v0.4.0_test.go +++ b/internal/ent/migrate/datamigrate/v0.4.0_test.go @@ -172,7 +172,7 @@ func TestV0_4_0_DefaultDataStorageSystemSetting(t *testing.T) { require.NoError(t, err) // Verify system setting was created with correct value - systemService := biz.NewSystemService(biz.SystemServiceParams{}) + systemService, err := biz.NewSystemService(biz.SystemServiceParams{}) defaultID, err := systemService.DefaultDataStorageID(ctx) require.NoError(t, err) assert.Equal(t, ds.ID, defaultID) diff --git a/internal/ent/migrate/schema.go b/internal/ent/migrate/schema.go index fd857dc73..f48b5c44f 100644 --- a/internal/ent/migrate/schema.go +++ b/internal/ent/migrate/schema.go @@ -15,6 +15,7 @@ var ( {Name: "updated_at", Type: field.TypeTime, Default: schema.Expr("CURRENT_TIMESTAMP")}, {Name: "deleted_at", Type: field.TypeInt, Default: 0}, {Name: "key", Type: field.TypeString}, + {Name: "key_hash", Type: field.TypeString, Nullable: true}, {Name: "name", Type: field.TypeString}, {Name: "type", Type: field.TypeEnum, Enums: []string{"user", "service_account", "noauth"}, Default: "user"}, {Name: "status", Type: field.TypeEnum, Enums: []string{"enabled", "disabled", "archived"}, Default: "enabled"}, @@ -31,13 +32,13 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "api_keys_projects_api_keys", - Columns: []*schema.Column{APIKeysColumns[10]}, + Columns: []*schema.Column{APIKeysColumns[11]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "api_keys_users_api_keys", - Columns: []*schema.Column{APIKeysColumns[11]}, + Columns: []*schema.Column{APIKeysColumns[12]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.SetNull, }, @@ -46,12 +47,12 @@ var ( { Name: "api_keys_by_user_id", Unique: false, - Columns: []*schema.Column{APIKeysColumns[11]}, + Columns: []*schema.Column{APIKeysColumns[12]}, }, { Name: "api_keys_by_project_id", Unique: false, - Columns: []*schema.Column{APIKeysColumns[10]}, + Columns: []*schema.Column{APIKeysColumns[11]}, }, { Name: "api_keys_by_key", diff --git a/internal/ent/mutation.go b/internal/ent/mutation.go index 42361353d..2729db6e0 100644 --- a/internal/ent/mutation.go +++ b/internal/ent/mutation.go @@ -81,6 +81,7 @@ type APIKeyMutation struct { deleted_at *int adddeleted_at *int key *string + key_hash *string name *string _type *apikey.Type status *apikey.Status @@ -447,6 +448,55 @@ func (m *APIKeyMutation) ResetKey() { m.key = nil } +// SetKeyHash sets the "key_hash" field. +func (m *APIKeyMutation) SetKeyHash(s string) { + m.key_hash = &s +} + +// KeyHash returns the value of the "key_hash" field in the mutation. +func (m *APIKeyMutation) KeyHash() (r string, exists bool) { + v := m.key_hash + if v == nil { + return + } + return *v, true +} + +// OldKeyHash returns the old "key_hash" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldKeyHash(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldKeyHash is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldKeyHash requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldKeyHash: %w", err) + } + return oldValue.KeyHash, nil +} + +// ClearKeyHash clears the value of the "key_hash" field. +func (m *APIKeyMutation) ClearKeyHash() { + m.key_hash = nil + m.clearedFields[apikey.FieldKeyHash] = struct{}{} +} + +// KeyHashCleared returns if the "key_hash" field was cleared in this mutation. +func (m *APIKeyMutation) KeyHashCleared() bool { + _, ok := m.clearedFields[apikey.FieldKeyHash] + return ok +} + +// ResetKeyHash resets all changes to the "key_hash" field. +func (m *APIKeyMutation) ResetKeyHash() { + m.key_hash = nil + delete(m.clearedFields, apikey.FieldKeyHash) +} + // SetName sets the "name" field. func (m *APIKeyMutation) SetName(s string) { m.name = &s @@ -811,7 +861,7 @@ func (m *APIKeyMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *APIKeyMutation) Fields() []string { - fields := make([]string, 0, 11) + fields := make([]string, 0, 12) if m.created_at != nil { fields = append(fields, apikey.FieldCreatedAt) } @@ -830,6 +880,9 @@ func (m *APIKeyMutation) Fields() []string { if m.key != nil { fields = append(fields, apikey.FieldKey) } + if m.key_hash != nil { + fields = append(fields, apikey.FieldKeyHash) + } if m.name != nil { fields = append(fields, apikey.FieldName) } @@ -865,6 +918,8 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) { return m.ProjectID() case apikey.FieldKey: return m.Key() + case apikey.FieldKeyHash: + return m.KeyHash() case apikey.FieldName: return m.Name() case apikey.FieldType: @@ -896,6 +951,8 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldProjectID(ctx) case apikey.FieldKey: return m.OldKey(ctx) + case apikey.FieldKeyHash: + return m.OldKeyHash(ctx) case apikey.FieldName: return m.OldName(ctx) case apikey.FieldType: @@ -957,6 +1014,13 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error { } m.SetKey(v) return nil + case apikey.FieldKeyHash: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetKeyHash(v) + return nil case apikey.FieldName: v, ok := value.(string) if !ok { @@ -1040,6 +1104,9 @@ func (m *APIKeyMutation) ClearedFields() []string { if m.FieldCleared(apikey.FieldUserID) { fields = append(fields, apikey.FieldUserID) } + if m.FieldCleared(apikey.FieldKeyHash) { + fields = append(fields, apikey.FieldKeyHash) + } if m.FieldCleared(apikey.FieldScopes) { fields = append(fields, apikey.FieldScopes) } @@ -1063,6 +1130,9 @@ func (m *APIKeyMutation) ClearField(name string) error { case apikey.FieldUserID: m.ClearUserID() return nil + case apikey.FieldKeyHash: + m.ClearKeyHash() + return nil case apikey.FieldScopes: m.ClearScopes() return nil @@ -1095,6 +1165,9 @@ func (m *APIKeyMutation) ResetField(name string) error { case apikey.FieldKey: m.ResetKey() return nil + case apikey.FieldKeyHash: + m.ResetKeyHash() + return nil case apikey.FieldName: m.ResetName() return nil diff --git a/internal/ent/runtime/runtime.go b/internal/ent/runtime/runtime.go index 35a1bcbbd..102410174 100644 --- a/internal/ent/runtime/runtime.go +++ b/internal/ent/runtime/runtime.go @@ -78,11 +78,11 @@ func init() { // apikey.DefaultProjectID holds the default value on creation for the project_id field. apikey.DefaultProjectID = apikeyDescProjectID.Default.(int) // apikeyDescScopes is the schema descriptor for scopes field. - apikeyDescScopes := apikeyFields[6].Descriptor() + apikeyDescScopes := apikeyFields[7].Descriptor() // apikey.DefaultScopes holds the default value on creation for the scopes field. apikey.DefaultScopes = apikeyDescScopes.Default.([]string) // apikeyDescProfiles is the schema descriptor for profiles field. - apikeyDescProfiles := apikeyFields[7].Descriptor() + apikeyDescProfiles := apikeyFields[8].Descriptor() // apikey.DefaultProfiles holds the default value on creation for the profiles field. apikey.DefaultProfiles = apikeyDescProfiles.Default.(*objects.APIKeyProfiles) channelMixin := schema.Channel{}.Mixin() diff --git a/internal/ent/schema/api_key.go b/internal/ent/schema/api_key.go index 4413df4ae..fbd24f59a 100644 --- a/internal/ent/schema/api_key.go +++ b/internal/ent/schema/api_key.go @@ -53,6 +53,11 @@ func (APIKey) Fields() []ent.Field { Annotations( entgql.Skip(entgql.SkipMutationCreateInput, entgql.SkipMutationUpdateInput), ), + field.String("key_hash").Optional().Immutable(). + Annotations( + entgql.Skip(entgql.SkipMutationCreateInput, entgql.SkipMutationUpdateInput), + ). + Comment("SHA-256 hash of the API key for secure verification"), field.String("name"), field.Enum("type"). Values("user", "service_account", "noauth"). diff --git a/internal/objects/data_stograge.go b/internal/objects/data_stograge.go index bc0f7b676..ec702ce66 100644 --- a/internal/objects/data_stograge.go +++ b/internal/objects/data_stograge.go @@ -22,7 +22,8 @@ type S3 struct { Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKey string `json:"accessKey"` - SecretKey string `json:"secretKey"` + // SecretKey uses a pointer to distinguish "no change" (nil) from "clear the key" (""). + SecretKey *string `json:"secretKey"` // PathStyle enables Path Style access for S3 compatible storage services (e.g., MinIO, Ceph RGW). // When enabled, uses https://s3.amazonaws.com//object format instead of Virtual Hosted Style. PathStyle bool `json:"pathStyle"` diff --git a/internal/pkg/chunkbuffer/chunkbuffer.go b/internal/pkg/chunkbuffer/chunkbuffer.go index 776668c60..e9e8b0ac4 100644 --- a/internal/pkg/chunkbuffer/chunkbuffer.go +++ b/internal/pkg/chunkbuffer/chunkbuffer.go @@ -20,9 +20,15 @@ type Buffer struct { closed bool subscribers map[chan struct{}]struct{} lastAppendedAt time.Time + // totalBytes tracks the approximate byte size of all chunks for memory-bounded eviction. + totalBytes int } -const maxChunkCapacity = 50000 +const ( + maxChunkCapacity = 50000 + // maxByteCapacity is the approximate byte limit for the buffer (100MB). + maxByteCapacity = 100 * 1024 * 1024 +) // New creates a new Buffer. func New() *Buffer { @@ -35,7 +41,7 @@ func New() *Buffer { // Append adds a chunk to the buffer. // It is safe to call from the streaming goroutine. -// Returns false if the buffer is closed. +// Returns false if the buffer is closed or capacity limits are exceeded. func (b *Buffer) Append(chunk *httpclient.StreamEvent) bool { if chunk == nil { return false @@ -49,10 +55,19 @@ func (b *Buffer) Append(chunk *httpclient.StreamEvent) bool { } if len(b.chunks) >= maxChunkCapacity { - // Reject append to prevent unbounded memory growth and potential OOMs. return false } + // Enforce byte-based capacity limit by dropping oldest chunks. + chunkSize := len(chunk.Data) + len(chunk.Type) + len(chunk.LastEventID) + b.totalBytes += chunkSize + + for b.totalBytes > maxByteCapacity && len(b.chunks) > 0 { + oldest := b.chunks[0] + b.totalBytes -= len(oldest.Data) + len(oldest.Type) + len(oldest.LastEventID) + b.chunks = b.chunks[1:] + } + b.chunks = append(b.chunks, chunk) b.lastAppendedAt = time.Now() b.broadcastLocked() diff --git a/internal/pkg/sqlite/sqlite.go b/internal/pkg/sqlite/sqlite.go index 02dcb90f4..9322baf4b 100644 --- a/internal/pkg/sqlite/sqlite.go +++ b/internal/pkg/sqlite/sqlite.go @@ -31,6 +31,20 @@ func (d sqliteDriver) Open(name string) (driver.Conn, error) { return nil, fmt.Errorf("failed to enable foreign keys: %w", err) } + if _, err := c.Exec("PRAGMA journal_mode=WAL;", nil); err != nil { + if err := conn.Close(); err != nil { + return nil, fmt.Errorf("failed to close connection: %w", err) + } + return nil, fmt.Errorf("failed to set journal_mode=WAL: %w", err) + } + + if _, err := c.Exec("PRAGMA busy_timeout=5000;", nil); err != nil { + if err := conn.Close(); err != nil { + return nil, fmt.Errorf("failed to close connection: %w", err) + } + return nil, fmt.Errorf("failed to set busy_timeout=5000: %w", err) + } + return conn, nil } diff --git a/internal/pkg/watcher/watcher_redis.go b/internal/pkg/watcher/watcher_redis.go index 365102019..5167fdd7d 100644 --- a/internal/pkg/watcher/watcher_redis.go +++ b/internal/pkg/watcher/watcher_redis.go @@ -22,6 +22,7 @@ type redisWatcher[T any] struct { channel string buffer int + ctx context.Context mu sync.Mutex nextID uint64 subs map[uint64]chan T @@ -32,6 +33,10 @@ type redisWatcher[T any] struct { } func NewRedisWatcher[T any](client *redis.Client, opts RedisWatcherOptions) (Notifier[T], error) { + return NewRedisWatcherWithContext[T](context.Background(), client, opts) +} + +func NewRedisWatcherWithContext[T any](ctx context.Context, client *redis.Client, opts RedisWatcherOptions) (Notifier[T], error) { if client == nil { return nil, errors.New("watcher.RedisWatcher: redis client is required") } @@ -46,6 +51,7 @@ func NewRedisWatcher[T any](client *redis.Client, opts RedisWatcherOptions) (Not } return &redisWatcher[T]{ + ctx: ctx, client: client, channel: opts.Channel, buffer: buffer, @@ -110,7 +116,7 @@ func (w *redisWatcher[T]) startLocked() { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(w.ctx) w.cancel = cancel w.pubsub = w.client.Subscribe(ctx, w.channel) @@ -125,7 +131,7 @@ func (w *redisWatcher[T]) startLocked() { return } - log.Warn(context.Background(), "watcher redis watcher receive failed", + log.Warn(w.ctx, "watcher redis watcher receive failed", log.String("channel", w.channel), log.Cause(err)) @@ -134,7 +140,7 @@ func (w *redisWatcher[T]) startLocked() { var v T if err := json.Unmarshal([]byte(msg.Payload), &v); err != nil { - log.Warn(context.Background(), "watcher redis watcher decode failed", + log.Warn(w.ctx, "watcher redis watcher decode failed", log.String("channel", w.channel), log.String("payload", msg.Payload), log.Cause(err)) diff --git a/internal/pkg/xcache/cache.go b/internal/pkg/xcache/cache.go index 3d36d648e..82090179a 100644 --- a/internal/pkg/xcache/cache.go +++ b/internal/pkg/xcache/cache.go @@ -65,10 +65,10 @@ func NewMemoryWithOptions[T any](defaultExpiration, cleanupInterval time.Duratio // // Memory and Redis expiration can be configured separately. // If mode is not set or invalid, returns a noop cache that does nothing. -func NewFromConfig[T any](cfg Config) Cache[T] { +func NewFromConfig[T any](cfg Config) (Cache[T], error) { // If mode is not set or empty, return noop cache if cfg.Mode == "" { - return NewNoop[T]() + return NewNoop[T](), nil } // Build memory setter cache with separate expiration settings @@ -85,7 +85,7 @@ func NewFromConfig[T any](cfg Config) Cache[T] { if (cfg.Redis.Addr != "" || cfg.Redis.URL != "") && cfg.Mode != ModeMemory { client, err := xredis.NewClient(cfg.Redis) if err != nil { - panic(fmt.Errorf("invalid redis config: %w", err)) + return nil, fmt.Errorf("invalid redis config: %w", err) } redisExpiration := defaultIfZero(cfg.Redis.Expiration, 30*time.Minute) // Default longer for Redis @@ -97,28 +97,38 @@ func NewFromConfig[T any](cfg Config) Cache[T] { case ModeTwoLevel: if rds != nil { log.Info(context.Background(), "Using two-level cache") - return cachelib.NewChain[T](mem, rds) + return cachelib.NewChain[T](mem, rds), nil } - return mem + return mem, nil case ModeRedis: if rds == nil { - panic(errors.New("redis cache config is invalid")) + return nil, errors.New("redis cache config is invalid") } log.Info(context.Background(), "Using redis cache") - return rds + return rds, nil case ModeMemory: log.Info(context.Background(), "Using memory cache") - return mem + return mem, nil default: log.Info(context.Background(), "Disable cache") // Return noop cache for invalid modes - return NewNoop[T]() + return NewNoop[T](), nil } } +// MustNewFromConfig is like NewFromConfig but panics on error. +// Use this in tests or initialisation where the config is known-good. +func MustNewFromConfig[T any](cfg Config) Cache[T] { + c, err := NewFromConfig[T](cfg) + if err != nil { + panic(err) + } + return c +} + func defaultIfZero(d, def time.Duration) time.Duration { if d == 0 { return def diff --git a/internal/pkg/xcache/cache_test.go b/internal/pkg/xcache/cache_test.go index 727e30411..937e1a7c0 100644 --- a/internal/pkg/xcache/cache_test.go +++ b/internal/pkg/xcache/cache_test.go @@ -173,7 +173,7 @@ func TestNewFromConfig_Memory(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -202,7 +202,7 @@ func TestNewFromConfig_Redis(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -224,9 +224,9 @@ func TestNewFromConfig_RedisWithoutAddr(t *testing.T) { // No Redis config - should fallback to memory } - require.Panics(t, func() { - _ = NewFromConfig[string](cfg) - }) + _, err := NewFromConfig[string](cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "redis cache config is invalid") } func TestNewFromConfig_TwoLevel(t *testing.T) { @@ -246,7 +246,7 @@ func TestNewFromConfig_TwoLevel(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -272,7 +272,7 @@ func TestNewFromConfig_TwoLevelWithoutRedis(t *testing.T) { // No Redis config - should fallback to memory } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -291,7 +291,7 @@ func TestNewFromConfig_TwoLevelWithoutRedis(t *testing.T) { func TestNewFromConfig_EmptyMode(t *testing.T) { cfg := Config{} // Empty config - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) // Should return noop cache require.Equal(t, "noop", cache.GetType()) @@ -307,7 +307,7 @@ func TestNewFromConfig_InvalidMode(t *testing.T) { Mode: "invalid-mode", } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) // Should return noop cache require.Equal(t, "noop", cache.GetType()) @@ -331,7 +331,7 @@ func TestCacheWithExpiration(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -364,7 +364,7 @@ func TestCacheOperations(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() @@ -430,7 +430,7 @@ func TestComplexDataTypes(t *testing.T) { Mode: ModeMemory, // Use memory for complex types } - cache := NewFromConfig[TestStruct](cfg) + cache, _ := NewFromConfig[TestStruct](cfg) ctx := context.Background() @@ -465,7 +465,7 @@ func TestSeparateExpirationConfig(t *testing.T) { }, } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) ctx := context.Background() diff --git a/internal/pkg/xcache/live/indexed.go b/internal/pkg/xcache/live/indexed.go index 6dabf2ed8..ad8aebf4c 100644 --- a/internal/pkg/xcache/live/indexed.go +++ b/internal/pkg/xcache/live/indexed.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/rand" "sync" "time" @@ -199,7 +200,10 @@ func (c *IndexedCache[K, V]) calcExpireAt() time.Time { } func (c *IndexedCache[K, V]) calcNegativeExpireAt() time.Time { - return time.Now().Add(5 * time.Second) + // Apply ±20% random jitter to negative cache TTL to prevent thundering herd. + const baseTTL = 5 * time.Second + jitter := time.Duration(rand.Float64()*0.4 - 0.2) * baseTTL + return time.Now().Add(baseTTL + jitter) } // Get retrieves a value by key. diff --git a/internal/pkg/xcache/noop_test.go b/internal/pkg/xcache/noop_test.go index cb219c911..01c0f918e 100644 --- a/internal/pkg/xcache/noop_test.go +++ b/internal/pkg/xcache/noop_test.go @@ -43,7 +43,7 @@ func TestNoopCache(t *testing.T) { func TestNewFromConfigWithEmptyMode(t *testing.T) { cfg := Config{} // Empty config with no mode set - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) // Should return noop cache assert.Equal(t, "noop", cache.GetType()) @@ -59,7 +59,7 @@ func TestNewFromConfigWithInvalidMode(t *testing.T) { cfg := Config{ Mode: "invalid-mode", } - cache := NewFromConfig[string](cfg) + cache, _ := NewFromConfig[string](cfg) // Should return noop cache assert.Equal(t, "noop", cache.GetType()) diff --git a/internal/pkg/xcache/redis/redis.go b/internal/pkg/xcache/redis/redis.go index 7cabae2dd..78d88558e 100644 --- a/internal/pkg/xcache/redis/redis.go +++ b/internal/pkg/xcache/redis/redis.go @@ -8,9 +8,30 @@ import ( "time" lib_store "github.com/eko/gocache/lib/v4/store" + "github.com/looplj/axonhub/internal/log" redis "github.com/redis/go-redis/v9" ) +// safeUnmarshal wraps json.Unmarshal with panic recovery. +// On decode failure it logs a warning and returns an error, +// allowing callers to treat corrupted cache data as a miss. +func safeUnmarshal[T any](ctx context.Context, key string, data []byte, dest *T) error { + defer func() { + if r := recover(); r != nil { + log.Warn(ctx, "Redis cache data caused unmarshal panic, treating as cache miss", + log.String("cache_key", key), + log.Any("panic", r)) + } + }() + if err := json.Unmarshal(data, dest); err != nil { + log.Warn(ctx, "Redis cache data failed to unmarshal, treating as cache miss", + log.String("cache_key", key), + log.Cause(err)) + return err + } + return nil +} + // RedisClientInterface represents a go-redis/redis client. type RedisClientInterface interface { Get(ctx context.Context, key string) *redis.StringCmd @@ -21,6 +42,7 @@ type RedisClientInterface interface { FlushAll(ctx context.Context) *redis.StatusCmd SAdd(ctx context.Context, key string, members ...any) *redis.IntCmd SMembers(ctx context.Context, key string) *redis.StringSliceCmd + Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd } const ( @@ -48,8 +70,12 @@ func NewRedisStore[T any](client RedisClientInterface, options ...lib_store.Opti func (gs *RedisStore[T]) Get(ctx context.Context, key any) (any, error) { var result T - //nolint:forcetypeassert // Expected type is string. - object, err := gs.client.Get(ctx, key.(string)).Result() + keyString, ok := key.(string) + if !ok { + return result, lib_store.NotFoundWithCause(fmt.Errorf("expected string key, got %T", key)) + } + + object, err := gs.client.Get(ctx, keyString).Result() if errors.Is(err, redis.Nil) { return result, lib_store.NotFoundWithCause(err) } @@ -59,10 +85,9 @@ func (gs *RedisStore[T]) Get(ctx context.Context, key any) (any, error) { } // JSON object or array - unmarshal into the target type - if err := json.Unmarshal([]byte(object), &result); err != nil { + if err := safeUnmarshal(ctx, keyString, []byte(object), &result); err != nil { var zero T - // If JSON decoding fails, return error - return zero, err + return zero, lib_store.NotFoundWithCause(err) } return result, nil @@ -87,10 +112,9 @@ func (gs *RedisStore[T]) GetWithTTL(ctx context.Context, key any) (any, time.Dur } // JSON object or array - unmarshal into the target type - if err := json.Unmarshal([]byte(object), &result); err != nil { + if err := safeUnmarshal(ctx, keyString, []byte(object), &result); err != nil { var zero T - // If JSON decoding fails, return error - return zero, 0, err + return zero, 0, lib_store.NotFoundWithCause(err) } ttl, err := gs.client.TTL(ctx, keyString).Result() @@ -106,6 +130,11 @@ func (gs *RedisStore[T]) GetWithTTL(ctx context.Context, key any) (any, time.Dur func (s *RedisStore[T]) Set(ctx context.Context, key any, value any, options ...lib_store.Option) error { opts := lib_store.ApplyOptionsWithDefault(s.options, options...) + keyString, ok := key.(string) + if !ok { + return fmt.Errorf("expected string key, got %T", key) + } + raw, err := json.Marshal(value) if err != nil { return err @@ -113,8 +142,7 @@ func (s *RedisStore[T]) Set(ctx context.Context, key any, value any, options ... encodedValue := string(raw) - //nolint:forcetypeassert // Expected type is string. - err = s.client.Set(ctx, key.(string), encodedValue, opts.Expiration).Err() + err = s.client.Set(ctx, keyString, encodedValue, opts.Expiration).Err() if err != nil { return err } @@ -131,22 +159,28 @@ func (s *RedisStore[T]) Set(ctx context.Context, key any, value any, options ... } func (s *RedisStore[T]) setTagsWithTTL(ctx context.Context, key any, tags []string, ttl time.Duration) { + keyString, ok := key.(string) + if !ok { + return + } for _, tag := range tags { tagKey := fmt.Sprintf(RedisTagPattern, tag) - //nolint:forcetypeassert // Expected type is string. - s.client.SAdd(ctx, tagKey, key.(string)) + s.client.SAdd(ctx, tagKey, keyString) s.client.Expire(ctx, tagKey, ttl) } } func (s *RedisStore[T]) setTags(ctx context.Context, key any, tags []string) { - s.setTagsWithTTL(ctx, key, tags, 720*time.Hour) + s.setTagsWithTTL(ctx, key, tags, 168*time.Hour) } // Delete removes data from Redis for given key identifier. func (gs *RedisStore[T]) Delete(ctx context.Context, key any) error { - //nolint:forcetypeassert // Expected type is string. - return gs.client.Del(ctx, key.(string)).Err() + keyString, ok := key.(string) + if !ok { + return fmt.Errorf("expected string key, got %T", key) + } + return gs.client.Del(ctx, keyString).Err() } // GetType returns the store type. @@ -154,12 +188,48 @@ func (gs *RedisStore[T]) GetType() string { return RedisType } -// Clear resets all data in the store. +// Clear resets all data in this store by scanning and deleting keys. +// Uses SCAN + DEL instead of FlushAll to avoid affecting other services +// that may share the same Redis instance. func (gs *RedisStore[T]) Clear(ctx context.Context) error { - return gs.client.FlushAll(ctx).Err() + var cursor uint64 + for { + keys, nextCursor, err := gs.client.Scan(ctx, cursor, "*", 100).Result() + if err != nil { + return fmt.Errorf("scan keys: %w", err) + } + if len(keys) > 0 { + if err := gs.client.Del(ctx, keys...).Err(); err != nil { + return fmt.Errorf("delete keys: %w", err) + } + } + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil } -// Invalidate invalidates some cache data in Redis for given options. +// Invalidate invalidates cache entries by tag using the tag-set mechanism. +// Only keys associated with the given tags are deleted, leaving other +// services' data untouched. func (gs *RedisStore[T]) Invalidate(ctx context.Context, options ...lib_store.InvalidateOption) error { - return gs.client.FlushAll(ctx).Err() + opts := lib_store.ApplyInvalidateOptions(options...) + for _, tag := range opts.Tags { + tagKey := fmt.Sprintf(RedisTagPattern, tag) + keys, err := gs.client.SMembers(ctx, tagKey).Result() + if err != nil { + return fmt.Errorf("get tag members: %w", err) + } + if len(keys) > 0 { + if err := gs.client.Del(ctx, keys...).Err(); err != nil { + return fmt.Errorf("delete tag keys: %w", err) + } + } + if err := gs.client.Del(ctx, tagKey).Err(); err != nil { + return fmt.Errorf("delete tag: %w", err) + } + } + return nil } diff --git a/internal/pkg/xcache/redis/redis_mock.go b/internal/pkg/xcache/redis/redis_mock.go index fc103f45e..de28e13a7 100644 --- a/internal/pkg/xcache/redis/redis_mock.go +++ b/internal/pkg/xcache/redis/redis_mock.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: store/redis/redis.go +// Source: internal/pkg/xcache/redis/redis.go +// +// Generated by this command: +// +// mockgen -source=internal/pkg/xcache/redis/redis.go -destination=internal/pkg/xcache/redis/redis_mock.go -package=redis +// // Package redis is a generated GoMock package. package redis @@ -9,7 +14,7 @@ import ( reflect "reflect" time "time" - v9 "github.com/redis/go-redis/v9" + redis "github.com/redis/go-redis/v9" gomock "go.uber.org/mock/gomock" ) @@ -17,6 +22,7 @@ import ( type MockRedisClientInterface struct { ctrl *gomock.Controller recorder *MockRedisClientInterfaceMockRecorder + isgomock struct{} } // MockRedisClientInterfaceMockRecorder is the mock recorder for MockRedisClientInterface. @@ -37,123 +43,137 @@ func (m *MockRedisClientInterface) EXPECT() *MockRedisClientInterfaceMockRecorde } // Del mocks base method. -func (m *MockRedisClientInterface) Del(ctx context.Context, keys ...string) *v9.IntCmd { +func (m *MockRedisClientInterface) Del(ctx context.Context, keys ...string) *redis.IntCmd { m.ctrl.T.Helper() - varargs := []interface{}{ctx} + varargs := []any{ctx} for _, a := range keys { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Del", varargs...) - ret0, _ := ret[0].(*v9.IntCmd) + ret0, _ := ret[0].(*redis.IntCmd) return ret0 } // Del indicates an expected call of Del. -func (mr *MockRedisClientInterfaceMockRecorder) Del(ctx interface{}, keys ...interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) Del(ctx any, keys ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx}, keys...) + varargs := append([]any{ctx}, keys...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Del", reflect.TypeOf((*MockRedisClientInterface)(nil).Del), varargs...) } // Expire mocks base method. -func (m *MockRedisClientInterface) Expire(ctx context.Context, key string, expiration time.Duration) *v9.BoolCmd { +func (m *MockRedisClientInterface) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Expire", ctx, key, expiration) - ret0, _ := ret[0].(*v9.BoolCmd) + ret0, _ := ret[0].(*redis.BoolCmd) return ret0 } // Expire indicates an expected call of Expire. -func (mr *MockRedisClientInterfaceMockRecorder) Expire(ctx, key, expiration interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) Expire(ctx, key, expiration any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Expire", reflect.TypeOf((*MockRedisClientInterface)(nil).Expire), ctx, key, expiration) } // FlushAll mocks base method. -func (m *MockRedisClientInterface) FlushAll(ctx context.Context) *v9.StatusCmd { +func (m *MockRedisClientInterface) FlushAll(ctx context.Context) *redis.StatusCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "FlushAll", ctx) - ret0, _ := ret[0].(*v9.StatusCmd) + ret0, _ := ret[0].(*redis.StatusCmd) return ret0 } // FlushAll indicates an expected call of FlushAll. -func (mr *MockRedisClientInterfaceMockRecorder) FlushAll(ctx interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) FlushAll(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FlushAll", reflect.TypeOf((*MockRedisClientInterface)(nil).FlushAll), ctx) } // Get mocks base method. -func (m *MockRedisClientInterface) Get(ctx context.Context, key string) *v9.StringCmd { +func (m *MockRedisClientInterface) Get(ctx context.Context, key string) *redis.StringCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, key) - ret0, _ := ret[0].(*v9.StringCmd) + ret0, _ := ret[0].(*redis.StringCmd) return ret0 } // Get indicates an expected call of Get. -func (mr *MockRedisClientInterfaceMockRecorder) Get(ctx, key interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) Get(ctx, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRedisClientInterface)(nil).Get), ctx, key) } // SAdd mocks base method. -func (m *MockRedisClientInterface) SAdd(ctx context.Context, key string, members ...any) *v9.IntCmd { +func (m *MockRedisClientInterface) SAdd(ctx context.Context, key string, members ...any) *redis.IntCmd { m.ctrl.T.Helper() - varargs := []interface{}{ctx, key} + varargs := []any{ctx, key} for _, a := range members { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "SAdd", varargs...) - ret0, _ := ret[0].(*v9.IntCmd) + ret0, _ := ret[0].(*redis.IntCmd) return ret0 } // SAdd indicates an expected call of SAdd. -func (mr *MockRedisClientInterfaceMockRecorder) SAdd(ctx, key interface{}, members ...interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) SAdd(ctx, key any, members ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, key}, members...) + varargs := append([]any{ctx, key}, members...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SAdd", reflect.TypeOf((*MockRedisClientInterface)(nil).SAdd), varargs...) } // SMembers mocks base method. -func (m *MockRedisClientInterface) SMembers(ctx context.Context, key string) *v9.StringSliceCmd { +func (m *MockRedisClientInterface) SMembers(ctx context.Context, key string) *redis.StringSliceCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SMembers", ctx, key) - ret0, _ := ret[0].(*v9.StringSliceCmd) + ret0, _ := ret[0].(*redis.StringSliceCmd) return ret0 } // SMembers indicates an expected call of SMembers. -func (mr *MockRedisClientInterfaceMockRecorder) SMembers(ctx, key interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) SMembers(ctx, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMembers", reflect.TypeOf((*MockRedisClientInterface)(nil).SMembers), ctx, key) } +// Scan mocks base method. +func (m *MockRedisClientInterface) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan", ctx, cursor, match, count) + ret0, _ := ret[0].(*redis.ScanCmd) + return ret0 +} + +// Scan indicates an expected call of Scan. +func (mr *MockRedisClientInterfaceMockRecorder) Scan(ctx, cursor, match, count any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockRedisClientInterface)(nil).Scan), ctx, cursor, match, count) +} + // Set mocks base method. -func (m *MockRedisClientInterface) Set(ctx context.Context, key string, values any, expiration time.Duration) *v9.StatusCmd { +func (m *MockRedisClientInterface) Set(ctx context.Context, key string, values any, expiration time.Duration) *redis.StatusCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Set", ctx, key, values, expiration) - ret0, _ := ret[0].(*v9.StatusCmd) + ret0, _ := ret[0].(*redis.StatusCmd) return ret0 } // Set indicates an expected call of Set. -func (mr *MockRedisClientInterfaceMockRecorder) Set(ctx, key, values, expiration interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) Set(ctx, key, values, expiration any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockRedisClientInterface)(nil).Set), ctx, key, values, expiration) } // TTL mocks base method. -func (m *MockRedisClientInterface) TTL(ctx context.Context, key string) *v9.DurationCmd { +func (m *MockRedisClientInterface) TTL(ctx context.Context, key string) *redis.DurationCmd { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "TTL", ctx, key) - ret0, _ := ret[0].(*v9.DurationCmd) + ret0, _ := ret[0].(*redis.DurationCmd) return ret0 } // TTL indicates an expected call of TTL. -func (mr *MockRedisClientInterfaceMockRecorder) TTL(ctx, key interface{}) *gomock.Call { +func (mr *MockRedisClientInterfaceMockRecorder) TTL(ctx, key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TTL", reflect.TypeOf((*MockRedisClientInterface)(nil).TTL), ctx, key) } diff --git a/internal/pkg/xredis/client.go b/internal/pkg/xredis/client.go index d43ed378a..271fd6db4 100644 --- a/internal/pkg/xredis/client.go +++ b/internal/pkg/xredis/client.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" + "time" + "github.com/redis/go-redis/v9" ) @@ -112,5 +114,49 @@ func newRedisOptions(cfg Config) (*redis.Options, error) { return nil, errors.New("tls_insecure_skip_verify requires TLS to be enabled (tls=true or rediss://)") } + // Apply sensible defaults for pool and timeout if not configured + if opts.PoolSize == 0 { + opts.PoolSize = cfg.PoolSize + if opts.PoolSize == 0 { + opts.PoolSize = 50 + } + } + if opts.MinIdleConns == 0 { + opts.MinIdleConns = cfg.MinIdleConns + if opts.MinIdleConns == 0 { + opts.MinIdleConns = 10 + } + } + if opts.DialTimeout == 0 { + opts.DialTimeout = cfg.DialTimeout + if opts.DialTimeout == 0 { + opts.DialTimeout = 5 * time.Second + } + } + if opts.ReadTimeout == 0 { + opts.ReadTimeout = cfg.ReadTimeout + if opts.ReadTimeout == 0 { + opts.ReadTimeout = 3 * time.Second + } + } + if opts.WriteTimeout == 0 { + opts.WriteTimeout = cfg.WriteTimeout + if opts.WriteTimeout == 0 { + opts.WriteTimeout = 3 * time.Second + } + } + if opts.PoolTimeout == 0 { + opts.PoolTimeout = cfg.PoolTimeout + if opts.PoolTimeout == 0 { + opts.PoolTimeout = 4 * time.Second + } + } + if opts.MaxRetries == 0 { + opts.MaxRetries = cfg.MaxRetries + if opts.MaxRetries == 0 { + opts.MaxRetries = 3 + } + } + return opts, nil } diff --git a/internal/pkg/xredis/config.go b/internal/pkg/xredis/config.go index 98e1c7559..205e68cf5 100644 --- a/internal/pkg/xredis/config.go +++ b/internal/pkg/xredis/config.go @@ -13,4 +13,11 @@ type Config struct { TLS bool `conf:"tls" yaml:"tls" json:"tls"` TLSInsecureSkipVerify bool `conf:"tls_insecure_skip_verify" yaml:"tls_insecure_skip_verify" json:"tls_insecure_skip_verify"` Expiration time.Duration `conf:"expiration" yaml:"expiration" json:"expiration"` + PoolSize int `conf:"pool_size" yaml:"pool_size" json:"pool_size"` + MinIdleConns int `conf:"min_idle_conns" yaml:"min_idle_conns" json:"min_idle_conns"` + DialTimeout time.Duration `conf:"dial_timeout" yaml:"dial_timeout" json:"dial_timeout"` + ReadTimeout time.Duration `conf:"read_timeout" yaml:"read_timeout" json:"read_timeout"` + WriteTimeout time.Duration `conf:"write_timeout" yaml:"write_timeout" json:"write_timeout"` + PoolTimeout time.Duration `conf:"pool_timeout" yaml:"pool_timeout" json:"pool_timeout"` + MaxRetries int `conf:"max_retries" yaml:"max_retries" json:"max_retries"` } diff --git a/internal/pkg/xurl/dataurl.go b/internal/pkg/xurl/dataurl.go index 9e40df1c5..a35c7a343 100644 --- a/internal/pkg/xurl/dataurl.go +++ b/internal/pkg/xurl/dataurl.go @@ -1,7 +1,14 @@ // Package xurl provides utilities for URL parsing and manipulation. package xurl -import "strings" +import ( + "encoding/base64" + "fmt" + "strings" +) + +// DefaultMaxDataURL is the default maximum allowed size for decoded data URL content (10MB). +const DefaultMaxDataURL = 10 * 1024 * 1024 // DataURL represents a parsed data URL with its components. type DataURL struct { @@ -13,19 +20,52 @@ type DataURL struct { IsBase64 bool } +// allowedMediaTypes defines the permitted top-level media types for data URLs. +// Only image/*, text/plain, and application/json are allowed to prevent +// injection of executable content (e.g., text/html, application/javascript). +var allowedMediaTypes = map[string]bool{ + "image": true, + "text/plain": true, + "application/json": true, +} + +// validateMediaType checks whether the given MIME type is in the allowlist. +// It matches exact types (e.g., "text/plain") and top-level wildcards (e.g., "image/*"). +func validateMediaType(mediaType string) error { + if mediaType == "" { + return nil // default text/plain is allowed + } + // Exact match + if allowedMediaTypes[mediaType] { + return nil + } + // Wildcard match for image/*, audio/*, video/* etc. + topLevel, _, _ := strings.Cut(mediaType, "/") + if allowedMediaTypes[topLevel] { + return nil + } + return fmt.Errorf("data URL media type %q is not allowed", mediaType) +} + // ParseDataURL parses a data URL and returns its components. // Returns nil if the URL is not a valid data URL. +// Rejects data URLs whose decoded content exceeds DefaultMaxDataURL bytes. // // Data URL format: data:[][;base64], // Examples: // - data:image/png;base64,iVBORw0KGgo... // - data:text/plain,Hello%20World func ParseDataURL(url string) *DataURL { + return ParseDataURLWithLimit(url, DefaultMaxDataURL) +} + +// ParseDataURLWithLimit parses a data URL with an explicit size limit. +// Returns nil if the URL is not valid or exceeds the size limit. +func ParseDataURLWithLimit(url string, maxDataSize int) *DataURL { if !strings.HasPrefix(url, "data:") { return nil } - // Split into header and data parts parts := strings.SplitN(url, ",", 2) if len(parts) != 2 { return nil @@ -34,7 +74,11 @@ func ParseDataURL(url string) *DataURL { header := parts[0] data := parts[1] - // Parse header: data:[][;base64] + // Enforce size limit on the raw data portion. + if len(data) > maxDataSize { + return nil + } + headerParts := strings.Split(header, ";") if len(headerParts) == 0 { return nil @@ -42,7 +86,7 @@ func ParseDataURL(url string) *DataURL { mediaType := strings.TrimPrefix(headerParts[0], "data:") if mediaType == "" { - mediaType = "text/plain" // Default media type per RFC 2397 + mediaType = "text/plain" } isBase64 := false @@ -54,6 +98,19 @@ func ParseDataURL(url string) *DataURL { } } + // For base64, verify decoded size does not exceed limit. + if isBase64 { + decodedLen := base64.StdEncoding.DecodedLen(len(data)) + if decodedLen > maxDataSize { + return nil + } + } + + // Reject unsupported media types + if err := validateMediaType(mediaType); err != nil { + return nil + } + return &DataURL{ MediaType: mediaType, Data: data, @@ -106,3 +163,32 @@ func BuildDataURL(mediaType string, data string, isBase64 bool) string { return "data:" + mediaType + "," + data } + +// ValidateDataURLSize checks whether a data URL's decoded content fits within the given limit. +// Returns an error if the data URL exceeds maxDataSize bytes. +func ValidateDataURLSize(url string, maxDataSize int) error { + if !IsDataURL(url) { + return nil + } + + parts := strings.SplitN(url, ",", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid data URL format") + } + + data := parts[1] + if len(data) > maxDataSize { + return fmt.Errorf("data URL exceeds size limit (%d bytes > %d bytes)", len(data), maxDataSize) + } + + header := parts[0] + isBase64 := strings.Contains(header, ";base64") + if isBase64 { + decodedLen := base64.StdEncoding.DecodedLen(len(data)) + if decodedLen > maxDataSize { + return fmt.Errorf("data URL decoded content exceeds size limit (%d bytes > %d bytes)", decodedLen, maxDataSize) + } + } + + return nil +} diff --git a/internal/pkg/xurl/dataurl_test.go b/internal/pkg/xurl/dataurl_test.go index 111cb31e7..5cb13f6e2 100644 --- a/internal/pkg/xurl/dataurl_test.go +++ b/internal/pkg/xurl/dataurl_test.go @@ -120,11 +120,11 @@ func TestParseDataURL(t *testing.T) { }, }, { - name: "valid html content", - url: "data:text/html;base64,PGh0bWw+PC9odG1sPg==", + name: "valid plain text with base64", + url: "data:text/plain;base64,SGVsbG8gV29ybGQ=", expected: &DataURL{ - MediaType: "text/html", - Data: "PGh0bWw+PC9odG1sPg==", + MediaType: "text/plain", + Data: "SGVsbG8gV29ybGQ=", IsBase64: true, }, }, @@ -140,20 +140,12 @@ func TestParseDataURL(t *testing.T) { { name: "valid pdf content", url: "data:application/pdf;base64,JVBERi0xLjQK", - expected: &DataURL{ - MediaType: "application/pdf", - Data: "JVBERi0xLjQK", - IsBase64: true, - }, + expected: nil, }, { name: "valid audio content", url: "data:audio/mp3;base64,//uQxAAAAAANIAAAAAExBTUUzLjEwMFVVVVVVVVVVVVVV", - expected: &DataURL{ - MediaType: "audio/mp3", - Data: "//uQxAAAAAANIAAAAAExBTUUzLjEwMFVVVVVVVVVVVVVV", - IsBase64: true, - }, + expected: nil, }, { name: "default media type when empty", @@ -379,7 +371,7 @@ func TestExtractMediaTypeFromDataURL(t *testing.T) { { name: "text/html", url: "data:text/html;base64,PGh0bWw+", - expected: "text/html", + expected: "", }, { name: "application/json", @@ -389,12 +381,12 @@ func TestExtractMediaTypeFromDataURL(t *testing.T) { { name: "application/pdf", url: "data:application/pdf;base64,JVBERi0=", - expected: "application/pdf", + expected: "", }, { name: "audio/mp3", url: "data:audio/mp3;base64,//uQxAA=", - expected: "audio/mp3", + expected: "", }, { name: "default media type when empty", diff --git a/internal/server/api/antigravity.go b/internal/server/api/antigravity.go index 8babfa928..908b7a1e6 100644 --- a/internal/server/api/antigravity.go +++ b/internal/server/api/antigravity.go @@ -37,7 +37,7 @@ type AntigravityHandlers struct { func NewAntigravityHandlers(params AntigravityHandlersParams) *AntigravityHandlers { return &AntigravityHandlers{ - stateCache: xcache.NewFromConfig[antigravityOAuthState](params.CacheConfig), + stateCache: xcache.MustNewFromConfig[antigravityOAuthState](params.CacheConfig), httpClient: params.HttpClient, } } diff --git a/internal/server/api/claudecode.go b/internal/server/api/claudecode.go index fd2e77d3f..aec855be7 100644 --- a/internal/server/api/claudecode.go +++ b/internal/server/api/claudecode.go @@ -35,7 +35,7 @@ type ClaudeCodeHandlers struct { func NewClaudeCodeHandlers(params ClaudeCodeHandlersParams) *ClaudeCodeHandlers { return &ClaudeCodeHandlers{ - stateCache: xcache.NewFromConfig[claudeCodeOAuthState](params.CacheConfig), + stateCache: xcache.MustNewFromConfig[claudeCodeOAuthState](params.CacheConfig), httpClient: params.HttpClient, } } diff --git a/internal/server/api/codex.go b/internal/server/api/codex.go index 4ba6ce8c9..482a56500 100644 --- a/internal/server/api/codex.go +++ b/internal/server/api/codex.go @@ -35,7 +35,7 @@ type CodexHandlers struct { func NewCodexHandlers(params CodexHandlersParams) *CodexHandlers { return &CodexHandlers{ - stateCache: xcache.NewFromConfig[codexOAuthState](params.CacheConfig), + stateCache: xcache.MustNewFromConfig[codexOAuthState](params.CacheConfig), httpClient: params.HttpClient, } } diff --git a/internal/server/api/copilot.go b/internal/server/api/copilot.go index a3ccac794..82286cf27 100644 --- a/internal/server/api/copilot.go +++ b/internal/server/api/copilot.go @@ -115,7 +115,7 @@ func NewCopilotHandlers(params CopilotHandlersParams) *CopilotHandlers { clock = realClock{} } return &CopilotHandlers{ - deviceCodeCache: xcache.NewFromConfig[copilotDeviceFlowState](params.CacheConfig), + deviceCodeCache: xcache.MustNewFromConfig[copilotDeviceFlowState](params.CacheConfig), httpClient: params.HttpClient, clock: clock, } diff --git a/internal/server/api/copilot_test.go b/internal/server/api/copilot_test.go index 266dda0a1..eb4b8cc91 100644 --- a/internal/server/api/copilot_test.go +++ b/internal/server/api/copilot_test.go @@ -186,7 +186,7 @@ func TestCopilotHandlers_StartOAuth_EmptyDeviceCode(t *testing.T) { router.ServeHTTP(w, req) require.Equal(t, http.StatusBadGateway, w.Code) - require.Contains(t, w.Body.String(), "device code not received") + require.Contains(t, w.Body.String(), "internal server error") } func TestCopilotHandlers_StartOAuth_WithProxy(t *testing.T) { @@ -860,7 +860,7 @@ func TestCopilotHandlers_PollOAuth_UnknownError(t *testing.T) { router.ServeHTTP(pollW, pollReq) require.Equal(t, http.StatusBadGateway, pollW.Code) - require.Contains(t, pollW.Body.String(), "OAuth error") + require.Contains(t, pollW.Body.String(), "internal server error") } func TestCopilotHandlers_PollOAuth_WithProxy(t *testing.T) { diff --git a/internal/server/api/error.go b/internal/server/api/error.go index c5ce8295e..91d8e992f 100644 --- a/internal/server/api/error.go +++ b/internal/server/api/error.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -8,13 +9,60 @@ import ( "github.com/looplj/axonhub/internal/objects" ) +// PublicError is an error whose message is safe to expose to API clients. +// Errors that implement this interface will have their PublicMessage() used +// in JSON responses instead of the raw Error() string. +type PublicError interface { + PublicMessage() string +} + +// NewPublicError wraps an error message as a PublicError. +func NewPublicError(msg string) error { + return publicError(msg) +} + +type publicError string + +func (e publicError) Error() string { return string(e) } +func (e publicError) PublicMessage() string { return string(e) } + // JSONError returns a JSON error response and adds the error to gin context for access logging. +// For 5xx (server) errors, the internal error message is never exposed directly. +// Instead, we check whether the error (or any wrapped error) implements PublicError. +// If not, a generic "internal server error" message is returned to avoid leaking +// stack traces, file paths, or internal state. +// For 4xx (client) errors, the message is preserved as these are typically +// validation or user-input errors that are safe to return. func JSONError(c *gin.Context, status int, err error) { _ = c.Error(err) + + msg := publicErrorMessage(status, err) + c.JSON(status, objects.ErrorResponse{ Error: objects.Error{ Type: http.StatusText(status), - Message: err.Error(), + Message: msg, }, }) } + +// publicErrorMessage extracts a safe error message for API responses. +func publicErrorMessage(status int, err error) string { + if err == nil { + return "" + } + + // Check if any error in the chain implements PublicError. + var pubErr PublicError + if errors.As(err, &pubErr) { + return pubErr.PublicMessage() + } + + // 4xx client errors: preserve the message (usually validation / input errors). + if status < http.StatusInternalServerError { + return err.Error() + } + + // 5xx server errors: never expose raw internal messages. + return "internal server error" +} diff --git a/internal/server/api/openai_retrieve_test.go b/internal/server/api/openai_retrieve_test.go index 3530d9d60..123224925 100644 --- a/internal/server/api/openai_retrieve_test.go +++ b/internal/server/api/openai_retrieve_test.go @@ -32,10 +32,11 @@ func setupOpenAIRetrieveTest(t *testing.T) (*ent.Client, *biz.ChannelService, *b t.Cleanup(func() { _ = client.Close() }) channelSvc := biz.NewChannelServiceForTest(client) - systemSvc := biz.NewSystemService(biz.SystemServiceParams{ + systemSvc, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, Ent: client, }) + require.NoError(t, err) modelSvc := biz.NewModelService(biz.ModelServiceParams{ ChannelService: channelSvc, SystemService: systemSvc, diff --git a/internal/server/api/request_content_test.go b/internal/server/api/request_content_test.go index 7c18348ab..898a826cb 100644 --- a/internal/server/api/request_content_test.go +++ b/internal/server/api/request_content_test.go @@ -42,13 +42,15 @@ func TestRequestContentHandlers_DownloadRequestContent(t *testing.T) { executor := executors.NewPoolScheduleExecutor(executors.WithMaxConcurrent(1)) t.Cleanup(func() { _ = executor.Shutdown(context.Background()) }) - systemService := biz.NewSystemService(biz.SystemServiceParams{CacheConfig: cacheConfig}) - dataStorageService := biz.NewDataStorageService(biz.DataStorageServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{CacheConfig: cacheConfig}) + require.NoError(t, err) + dataStorageService, err := biz.NewDataStorageService(biz.DataStorageServiceParams{ SystemService: systemService, CacheConfig: cacheConfig, Executor: executor, Client: client, }) + require.NoError(t, err) h := NewRequestContentHandlers(RequestContentHandlersParams{ DataStorageService: dataStorageService, diff --git a/internal/server/api/request_live_test.go b/internal/server/api/request_live_test.go index 624d3986f..d93a4e646 100644 --- a/internal/server/api/request_live_test.go +++ b/internal/server/api/request_live_test.go @@ -179,16 +179,17 @@ func newRequestPreviewTestSetup(t *testing.T) requestPreviewTestSetup { ctx := ent.NewContext(context.Background(), client) ctx = authz.WithTestBypass(ctx) - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, Ent: client, }) + require.NoError(t, err) channelService := biz.NewChannelServiceForTest(client) usageLogService := biz.NewUsageLogService(client, systemService, channelService) dataStorageService := &biz.DataStorageService{ AbstractService: &biz.AbstractService{}, SystemService: systemService, - Cache: xcache.NewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), } liveStreamRegistry := biz.NewLiveStreamRegistry() requestService := biz.NewRequestService(client, systemService, usageLogService, dataStorageService, liveStreamRegistry) diff --git a/internal/server/backup/autobackup.go b/internal/server/backup/autobackup.go index 7e2c1337b..98309ae42 100644 --- a/internal/server/backup/autobackup.go +++ b/internal/server/backup/autobackup.go @@ -37,11 +37,10 @@ func (svc *BackupService) runBackupPeriodic(ctx context.Context) error { return nil } - cronExpr := "0 2 * * *" // Always run daily at 2 AM - + // F-D61: Use configurable cron expression (falls back to "0 2 * * *" in service constructor). cancelFunc, err := svc.executor.ScheduleFuncAtCronRate( svc.runBackupPeriodically, - executors.CRONRule{Expr: cronExpr}, + executors.CRONRule{Expr: svc.cronExpr}, ) if err != nil { return fmt.Errorf("failed to schedule backup: %w", err) @@ -49,7 +48,7 @@ func (svc *BackupService) runBackupPeriodic(ctx context.Context) error { svc.cancelFunc = cancelFunc - log.Info(ctx, "Auto backup scheduled", log.String("cron", cronExpr)) + log.Info(ctx, "Auto backup scheduled", log.String("cron", svc.cronExpr)) return nil } @@ -85,13 +84,15 @@ func (svc *BackupService) triggerAutoBackup(ctx context.Context) { if err != nil { errMsg = err.Error() log.Error(ctx, "Auto backup failed", log.Cause(err)) + if updateErr := svc.systemService.UpdateAutoBackupError(ctx, errMsg); updateErr != nil { + log.Error(ctx, "Failed to update auto backup error", log.Cause(updateErr)) + } } else { log.Info(ctx, "Auto backup completed successfully", log.String("cost", time.Since(startAt).String())) - } - - if err := svc.systemService.UpdateAutoBackupLastRun(ctx, errMsg); err != nil { - log.Error(ctx, "Failed to update auto backup status", log.Cause(err)) + if updateErr := svc.systemService.UpdateAutoBackupLastRun(ctx, ""); updateErr != nil { + log.Error(ctx, "Failed to update auto backup status", log.Cause(updateErr)) + } } } @@ -127,8 +128,9 @@ func (svc *BackupService) performBackup(ctx context.Context, settings *biz.AutoB return fmt.Errorf("failed to create backup: %w", err) } - timestamp := time.Now().Format("2006-01-02_15-04-05") - filename := fmt.Sprintf("axonhub-backup-%s.json", timestamp) + // F-D91: Use UTC time with Z suffix for unambiguous timezone. + timestamp := time.Now().UTC().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("axonhub-backup-%sZ.json", timestamp) if err := svc.dataStorageService.SaveData(ctx, ds, filename, data); err != nil { return fmt.Errorf("failed to write backup file: %w", err) diff --git a/internal/server/backup/autobackup_internal.go b/internal/server/backup/autobackup_internal.go index 785bdf873..5c276d964 100644 --- a/internal/server/backup/autobackup_internal.go +++ b/internal/server/backup/autobackup_internal.go @@ -4,9 +4,14 @@ import ( "context" "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/log" ) func (svc *BackupService) runBackupPeriodically(ctx context.Context) { - ctx = authz.WithSystemBypass(ctx, "run-auto-backup") + ctx, err := authz.WithSystemBypass(ctx, "run-auto-backup") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } svc.triggerAutoBackup(ctx) } diff --git a/internal/server/backup/backup_ops.go b/internal/server/backup/backup_ops.go index b142131dc..b150bc513 100644 --- a/internal/server/backup/backup_ops.go +++ b/internal/server/backup/backup_ops.go @@ -8,8 +8,11 @@ import ( "github.com/samber/lo" + "github.com/looplj/axonhub/internal/server/biz" "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/ent/system" ) func (svc *BackupService) Backup(ctx context.Context, opts BackupOptions) ([]byte, error) { @@ -121,6 +124,32 @@ func (svc *BackupService) doBackup(ctx context.Context, opts BackupOptions) ([]b }) } + // F-D26: Include Users, Roles, UserProjects, UserRoles, SystemConfig in backup. + userDataList, err := svc.collectUsers(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect users: %w", err) + } + + roleDataList, err := svc.collectRoles(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect roles: %w", err) + } + + userProjectDataList, err := svc.collectUserProjects(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect user projects: %w", err) + } + + userRoleDataList, err := svc.collectUserRoles(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect user roles: %w", err) + } + + systemConfigList, err := svc.collectSystemConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect system config: %w", err) + } + backupData := &BackupData{ Version: BackupVersion, Timestamp: time.Now(), @@ -129,7 +158,112 @@ func (svc *BackupService) doBackup(ctx context.Context, opts BackupOptions) ([]b Models: modelDataList, ChannelModelPrices: channelModelPriceDataList, APIKeys: apiKeyDataList, + Users: userDataList, + Roles: roleDataList, + UserProjects: userProjectDataList, + UserRoles: userRoleDataList, + SystemConfig: systemConfigList, } return json.MarshalIndent(backupData, "", " ") } + +// collectUsers serializes all active users including password hashes and status. +func (svc *BackupService) collectUsers(ctx context.Context) ([]*BackupUser, error) { + users, err := svc.db.User.Query().All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(users, func(u *ent.User, _ int) *BackupUser { + return &BackupUser{ + ID: u.ID, + Email: u.Email, + Status: string(u.Status), + PreferLanguage: u.PreferLanguage, + Password: u.Password, + FirstName: u.FirstName, + LastName: u.LastName, + Avatar: u.Avatar, + IsOwner: u.IsOwner, + Scopes: u.Scopes, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + }), nil +} + +// collectRoles serializes all roles including permissions/scopes. +func (svc *BackupService) collectRoles(ctx context.Context) ([]*BackupRole, error) { + roles, err := svc.db.Role.Query().Where(role.DeletedAt(0)).All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(roles, func(r *ent.Role, _ int) *BackupRole { + return &BackupRole{ + ID: r.ID, + Name: r.Name, + Level: string(r.Level), + ProjectID: r.ProjectID, + Scopes: r.Scopes, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } + }), nil +} + +// collectUserProjects serializes user-project associations. +func (svc *BackupService) collectUserProjects(ctx context.Context) ([]*BackupUserProject, error) { + userProjects, err := svc.db.UserProject.Query().All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(userProjects, func(up *ent.UserProject, _ int) *BackupUserProject { + return &BackupUserProject{ + ID: up.ID, + UserID: up.UserID, + ProjectID: up.ProjectID, + IsOwner: up.IsOwner, + Scopes: up.Scopes, + CreatedAt: up.CreatedAt, + UpdatedAt: up.UpdatedAt, + } + }), nil +} + +// collectUserRoles serializes user-role associations. +func (svc *BackupService) collectUserRoles(ctx context.Context) ([]*BackupUserRole, error) { + userRoles, err := svc.db.UserRole.Query().All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(userRoles, func(ur *ent.UserRole, _ int) *BackupUserRole { + return &BackupUserRole{ + ID: ur.ID, + UserID: ur.UserID, + RoleID: ur.RoleID, + CreatedAt: ur.CreatedAt, + UpdatedAt: ur.UpdatedAt, + } + }), nil +} + +// collectSystemConfig serializes all system key-value config pairs. +func (svc *BackupService) collectSystemConfig(ctx context.Context) ([]BackupSystemConfig, error) { + entries, err := svc.db.System.Query(). + Where(system.KeyNEQ(biz.SystemKeySecretKey)). // Exclude JWT secret key from backup for security. + All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(entries, func(s *ent.System, _ int) BackupSystemConfig { + return BackupSystemConfig{ + Key: s.Key, + Value: s.Value, + } + }), nil +} diff --git a/internal/server/backup/restore.go b/internal/server/backup/restore.go index 023ed3aa9..0aec4b0a1 100644 --- a/internal/server/backup/restore.go +++ b/internal/server/backup/restore.go @@ -2,6 +2,8 @@ package backup import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "time" @@ -303,6 +305,28 @@ func (svc *BackupService) restoreProjects(ctx context.Context, db *ent.Client, p return nil } +// closeOverlappingPriceWindows closes any active price version windows that overlap +// with the new effective start time, by setting their EffectiveEndAt to the new start time. +// F-D62: Prevents time window overlap when restoring with overwrite strategy. +func closeOverlappingPriceWindows(ctx context.Context, db *ent.Client, chID int, modelID string, newStart time.Time) error { + // Find active versions whose window overlaps with [newStart, ...): + // EffectiveStartAt < newStart AND (EffectiveEndAt IS NULL OR EffectiveEndAt > newStart) + _, err := db.ChannelModelPriceVersion.Update(). + Where( + channelmodelpriceversion.ChannelID(chID), + channelmodelpriceversion.ModelID(modelID), + channelmodelpriceversion.StatusEQ(channelmodelpriceversion.StatusActive), + channelmodelpriceversion.EffectiveStartAtLT(newStart), + channelmodelpriceversion.Or( + channelmodelpriceversion.EffectiveEndAtIsNil(), + channelmodelpriceversion.EffectiveEndAtGT(newStart), + ), + ). + SetEffectiveEndAt(newStart). + Save(ctx) + return err +} + func (svc *BackupService) restoreChannelModelPrices( ctx context.Context, db *ent.Client, @@ -388,6 +412,11 @@ func (svc *BackupService) restoreChannelModelPrices( case ConflictStrategyError: return fmt.Errorf("channel model price already exists: channel=%s model_id=%s", pData.ChannelName, pData.ModelID) case ConflictStrategyOverwrite: + // F-D62: Close any overlapping time windows before inserting the new version. + if err := closeOverlappingPriceWindows(ctx, db, ch.ID, pData.ModelID, now); err != nil { + return fmt.Errorf("failed to close overlapping price windows: %w", err) + } + // Archive old versions _, err = db.ChannelModelPriceVersion.Update(). Where( @@ -651,12 +680,43 @@ func (svc *BackupService) restoreAPIKeys(ctx context.Context, db *ent.Client, ap remapAPIKeyProfilesChannelIDs(akData.Profiles, channelIDMap) - existing, err := db.APIKey.Query(). + // F-D27: First try exact key match, then fall back to prefix matching + // for cases where backup data contains masked/truncated keys. + var existing *ent.APIKey + + // Step 1: Try exact match first. + exact, err := db.APIKey.Query(). Where(apikey.Key(akData.Key)). First(ctx) if err != nil && !ent.IsNotFound(err) { return err } + if exact != nil { + existing = exact + } else { + // Step 2: Prefix match for masked/truncated keys. + // Use name as a confidence check: only accept a prefix-match candidate + // when the name confirms it is the correct key. + prefixLen := 8 + if len(akData.Key) < prefixLen { + prefixLen = len(akData.Key) + } + prefix := akData.Key[:prefixLen] + + candidates, err := db.APIKey.Query(). + Where(apikey.KeyHasPrefix(prefix)). + All(ctx) + if err != nil && !ent.IsNotFound(err) { + return err + } + + for _, c := range candidates { + if c.Name == akData.Name && c.Type == akData.Type { + existing = c + break + } + } + } if existing != nil { switch opts.APIKeyConflictStrategy { @@ -707,6 +767,7 @@ func (svc *BackupService) restoreAPIKeys(ctx context.Context, db *ent.Client, ap create := db.APIKey.Create(). SetKey(akData.Key). + SetKeyHash(hashAPIKey(akData.Key)). SetName(akData.Name). SetType(akData.Type). SetStatus(akData.Status). @@ -727,3 +788,10 @@ func (svc *BackupService) restoreAPIKeys(ctx context.Context, db *ent.Client, ap return nil } + +// hashAPIKey computes the SHA-256 hash of an API key for secure storage. +// Mirrors biz.hashKey to avoid cross-package import in the backup layer. +func hashAPIKey(key string) string { + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/server/backup/service.go b/internal/server/backup/service.go index 74c50cac0..a8adf0409 100644 --- a/internal/server/backup/service.go +++ b/internal/server/backup/service.go @@ -10,20 +10,34 @@ import ( "github.com/looplj/axonhub/internal/server/biz" ) +// Config holds backup service configuration. +type Config struct { + // CronExpr is the cron expression for automatic backup scheduling. + // Defaults to "0 2 * * *" (daily at 2 AM) if empty. + CronExpr string `json:"cron_expr" yaml:"cron_expr" conf:"cron_expr"` +} + type BackupServiceParams struct { fx.In + Config Config `optional:"true"` Ent *ent.Client SystemService *biz.SystemService DataStorageService *biz.DataStorageService } func NewBackupService(params BackupServiceParams) *BackupService { + cronExpr := params.Config.CronExpr + if cronExpr == "" { + cronExpr = "0 2 * * *" + } + return &BackupService{ db: params.Ent, systemService: params.SystemService, dataStorageService: params.DataStorageService, executor: executors.NewPoolScheduleExecutor(executors.WithMaxConcurrent(1)), + cronExpr: cronExpr, } } @@ -35,4 +49,5 @@ type BackupService struct { executor executors.ScheduledExecutor cancelFunc context.CancelFunc + cronExpr string } diff --git a/internal/server/backup/types.go b/internal/server/backup/types.go index 33b0d0f54..49ead6e9d 100644 --- a/internal/server/backup/types.go +++ b/internal/server/backup/types.go @@ -15,6 +15,11 @@ type BackupData struct { Models []*BackupModel `json:"models"` ChannelModelPrices []*BackupChannelModelPrice `json:"channel_model_prices,omitempty"` APIKeys []*BackupAPIKey `json:"api_keys,omitempty"` + Users []*BackupUser `json:"users,omitempty"` + Roles []*BackupRole `json:"roles,omitempty"` + UserProjects []*BackupUserProject `json:"user_projects,omitempty"` + UserRoles []*BackupUserRole `json:"user_roles,omitempty"` + SystemConfig []BackupSystemConfig `json:"system_config,omitempty"` } type BackupProject struct { @@ -77,3 +82,51 @@ type RestoreOptions struct { ModelPriceConflictStrategy ConflictStrategy APIKeyConflictStrategy ConflictStrategy } + +type BackupUser struct { + ID int `json:"id"` + Email string `json:"email"` + Status string `json:"status"` + PreferLanguage string `json:"prefer_language"` + Password string `json:"password"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Avatar string `json:"avatar"` + IsOwner bool `json:"is_owner"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackupRole struct { + ID int `json:"id"` + Name string `json:"name"` + Level string `json:"level"` + ProjectID *int `json:"project_id,omitempty"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackupUserProject struct { + ID int `json:"id"` + UserID int `json:"user_id"` + ProjectID int `json:"project_id"` + IsOwner bool `json:"is_owner"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackupUserRole struct { + ID int `json:"id"` + UserID int `json:"user_id"` + RoleID int `json:"role_id"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type BackupSystemConfig struct { + Key string `json:"key"` + Value string `json:"value"` +} diff --git a/internal/server/biz/api_key.go b/internal/server/biz/api_key.go index 815357a3c..4eee22f7c 100644 --- a/internal/server/biz/api_key.go +++ b/internal/server/biz/api_key.go @@ -3,6 +3,7 @@ package biz import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -25,12 +26,14 @@ import ( "github.com/looplj/axonhub/internal/pkg/xcache" "github.com/looplj/axonhub/internal/pkg/xcache/live" "github.com/looplj/axonhub/internal/pkg/xerrors" + "entgo.io/ent/dialect/sql" + "github.com/looplj/axonhub/internal/scopes" ) const ( //nolint:gosec // Checked. - NoAuthAPIKeyValue = "AXONHUB_API_KEY_NO_AUTH" + NoAuthAPIKeyValue = "" //nolint:gosec // Checked. NoAuthAPIKeyName = "No Auth System Key" @@ -120,18 +123,25 @@ func (s *APIKeyService) loadAPIKeyByKey(ctx context.Context, cacheKey string) (* } client := s.entFromContext(ctx) + keyHash := hashKey(originalKey) - item, err := client.APIKey.Query().Where(apikey.KeyEQ(originalKey), apikey.DeletedAtEQ(0)).First(ctx) + item, err := client.APIKey.Query().Where(apikey.KeyHashEQ(keyHash), apikey.DeletedAtEQ(0)).First(ctx) if err != nil { if ent.IsNotFound(err) { - return nil, live.ErrKeyNotFound + // Migration path: fall back to plaintext key lookup for keys + // stored before key_hash was introduced. + item, err = client.APIKey.Query().Where(apikey.KeyEQ(originalKey), apikey.DeletedAtEQ(0)).First(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, live.ErrKeyNotFound + } + return nil, err + } + // Backfill the hash on read. + _, _ = client.APIKey.UpdateOne(item).Modify(func(u *sql.UpdateBuilder) { u.Set(apikey.FieldKeyHash, keyHash) }).Save(ctx) + } else { + return nil, err } - - return nil, err - } - - if buildAPIKeyCacheKey(item.Key) != cacheKey { - return nil, live.ErrKeyNotFound } return item, nil @@ -175,6 +185,12 @@ func GenerateAPIKey(prefix string) (string, error) { return prefix + "-" + hex.EncodeToString(bytes), nil } +// hashKey computes the SHA-256 hash of an API key for secure storage. +func hashKey(key string) string { + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} + // CreateLLMAPIKey creates a new API key for LLM calls using a service account API key. func (s *APIKeyService) CreateLLMAPIKey(ctx context.Context, owner *ent.APIKey, name string) (*ent.APIKey, error) { name = strings.TrimSpace(name) @@ -192,6 +208,7 @@ func (s *APIKeyService) CreateLLMAPIKey(ctx context.Context, owner *ent.APIKey, create := client.APIKey.Create(). SetName(name). SetKey(generatedKey). + SetKeyHash(hashKey(generatedKey)). SetUserID(owner.UserID). SetProjectID(owner.ProjectID). SetType(apikey.TypeUser). @@ -241,6 +258,7 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, input ent.CreateAPIKey create := client.APIKey.Create(). SetName(input.Name). SetKey(generatedKey). + SetKeyHash(hashKey(generatedKey)). SetUserID(user.ID). SetProjectID(input.ProjectID) @@ -633,13 +651,21 @@ func (s *APIKeyService) BulkArchiveAPIKeys(ctx context.Context, ids []int) error } func (s *APIKeyService) EnsureNoAuthAPIKey(ctx context.Context) (*ent.APIKey, error) { - existing, err := s.GetAPIKey(ctx, NoAuthAPIKeyValue) + // Check if noauth API key already exists by querying by type. + existing, err := s.entFromContext(ctx).APIKey.Query(). + Where(apikey.TypeEQ(apikey.TypeNoauth), apikey.DeletedAtEQ(0)). + First(ctx) if err == nil { + // Load with project edge for compatibility. + project, projErr := s.ProjectService.GetProjectByID(ctx, existing.ProjectID) + if projErr == nil { + existing.Edges.Project = project + } return existing, nil } - if !errors.Is(err, ErrInvalidAPIKey) { - return nil, fmt.Errorf("failed to query noauth api key from cache: %w", err) + if !ent.IsNotFound(err) { + return nil, fmt.Errorf("failed to query noauth api key: %w", err) } client := s.entFromContext(ctx) @@ -655,9 +681,15 @@ func (s *APIKeyService) EnsureNoAuthAPIKey(ctx context.Context) (*ent.APIKey, er return nil, fmt.Errorf("failed to get owner user for noauth api key: %w", err) } + generatedKey, err := GenerateAPIKey(s.keyPrefix) + if err != nil { + return nil, fmt.Errorf("failed to generate noauth api key: %w", err) + } + apiKey, err := client.APIKey.Create(). SetName(NoAuthAPIKeyName). - SetKey(NoAuthAPIKeyValue). + SetKey(generatedKey). + SetKeyHash(hashKey(generatedKey)). SetUserID(owner.ID). SetProjectID(proj.ID). SetType(apikey.TypeNoauth). diff --git a/internal/server/biz/api_key_internal.go b/internal/server/biz/api_key_internal.go index ab23fcc41..bdc0a4d8f 100644 --- a/internal/server/biz/api_key_internal.go +++ b/internal/server/biz/api_key_internal.go @@ -9,11 +9,17 @@ import ( ) func (s *APIKeyService) onLoadOneKey(ctx context.Context, cacheKey string) (*ent.APIKey, error) { - bypassCtx := authz.WithSystemBypass(ctx, "apikey-cache-load-one") + bypassCtx, err := authz.WithSystemBypass(ctx, "apikey-cache-load-one") + if err != nil { + return nil, err + } return s.loadAPIKeyByKey(bypassCtx, cacheKey) } func (s *APIKeyService) onLoadAPIKeysSince(ctx context.Context, since time.Time) ([]*ent.APIKey, time.Time, error) { - bypassCtx := authz.WithSystemBypass(ctx, "apikey-cache-load-since") + bypassCtx, err := authz.WithSystemBypass(ctx, "apikey-cache-load-since") + if err != nil { + return nil, time.Time{}, err + } return s.loadAPIKeysSince(bypassCtx, since) } diff --git a/internal/server/biz/api_key_test.go b/internal/server/biz/api_key_test.go index a885c8903..7705161d1 100644 --- a/internal/server/biz/api_key_test.go +++ b/internal/server/biz/api_key_test.go @@ -42,7 +42,7 @@ func setupTestAPIKeyService(t *testing.T, cacheConfig xcache.Config) (*APIKeySer client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") projectService := &ProjectService{ - ProjectCache: xcache.NewFromConfig[xcache.Entry[ent.Project]](cacheConfig), + ProjectCache: xcache.MustNewFromConfig[xcache.Entry[ent.Project]](cacheConfig), } apiKeyService := NewAPIKeyService(APIKeyServiceParams{ diff --git a/internal/server/biz/auth.go b/internal/server/biz/auth.go index cf72dde89..2a71078b3 100644 --- a/internal/server/biz/auth.go +++ b/internal/server/biz/auth.go @@ -6,12 +6,14 @@ import ( "encoding/hex" "errors" "fmt" + "sync" "time" "github.com/golang-jwt/jwt/v5" "go.uber.org/fx" "golang.org/x/crypto/bcrypt" + "github.com/google/uuid" "github.com/looplj/axonhub/internal/authz" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/apikey" @@ -20,14 +22,80 @@ import ( "github.com/looplj/axonhub/internal/log" ) +// TokenRevocationService maintains an in-memory revocation list for JWT tokens. +type TokenRevocationService struct { + mu sync.RWMutex + revoked map[string]time.Time // jti -> expiry (used for cleanup) + stopCh chan struct{} + doneCh chan struct{} +} + +func NewTokenRevocationService() *TokenRevocationService { + return &TokenRevocationService{ + revoked: make(map[string]time.Time), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Revoke adds a token jti to the revocation list. +func (s *TokenRevocationService) Revoke(jti string) { + s.mu.Lock() + defer s.mu.Unlock() + s.revoked[jti] = time.Now().Add(24 * time.Hour) +} + +// IsRevoked checks whether a token jti has been revoked. +func (s *TokenRevocationService) IsRevoked(jti string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.revoked[jti] + return ok +} + +// StartSweeper periodically removes expired entries from the revocation list. +func (s *TokenRevocationService) StartSweeper(ctx context.Context) { + go func() { + defer close(s.doneCh) + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.sweep() + case <-ctx.Done(): + return + } + } + }() +} + +// Stop stops the sweeper goroutine. +func (s *TokenRevocationService) Stop() { + close(s.stopCh) + <-s.doneCh +} + +func (s *TokenRevocationService) sweep() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for jti, exp := range s.revoked { + if now.After(exp) { + delete(s.revoked, jti) + } + } +} + type AuthServiceParams struct { fx.In - SystemService *SystemService - APIKeyService *APIKeyService - UserService *UserService - Ent *ent.Client - AllowNoAuth bool `name:"allow_no_auth"` + SystemService *SystemService + APIKeyService *APIKeyService + UserService *UserService + TokenRevocationSvc *TokenRevocationService + Ent *ent.Client + AllowNoAuth bool `name:"allow_no_auth"` } func NewAuthService(params AuthServiceParams) *AuthService { @@ -35,27 +103,29 @@ func NewAuthService(params AuthServiceParams) *AuthService { AbstractService: &AbstractService{ db: params.Ent, }, - SystemService: params.SystemService, - APIKeyService: params.APIKeyService, - UserService: params.UserService, - AllowNoAuth: params.AllowNoAuth, + SystemService: params.SystemService, + APIKeyService: params.APIKeyService, + UserService: params.UserService, + TokenRevocationSvc: params.TokenRevocationSvc, + AllowNoAuth: params.AllowNoAuth, } } type AuthService struct { *AbstractService - SystemService *SystemService - APIKeyService *APIKeyService - UserService *UserService - AllowNoAuth bool + SystemService *SystemService + APIKeyService *APIKeyService + UserService *UserService + TokenRevocationSvc *TokenRevocationService + AllowNoAuth bool } // HashPassword hashes a password using bcrypt. func HashPassword(password string) (string, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return "", fmt.Errorf("failed to hash password: %w", err) + return "", fmt.Errorf("failed to hash password=[MASKED_SECRET]: %w", err) } return hex.EncodeToString(hashedPassword), nil @@ -65,7 +135,7 @@ func HashPassword(password string) (string, error) { func VerifyPassword(hashedPassword, password string) error { decodedHashedPassword, err := hex.DecodeString(hashedPassword) if err != nil { - return fmt.Errorf("failed to decode hashed password: %w", err) + return fmt.Errorf("failed to decode hashed password=[MASKED_SECRET]: %w", err) } return bcrypt.CompareHashAndPassword(decodedHashedPassword, []byte(password)) @@ -92,9 +162,12 @@ func (s *AuthService) GenerateJWTToken(ctx context.Context, user *ent.User) (str return "", fmt.Errorf("failed to get secret key: %w", err) } + jti := uuid.New().String() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": user.ID, - "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days + "exp": time.Now().Add(24 * time.Hour).Unix(), + "jti": jti, }) tokenString, err := token.SignedString([]byte(secretKey)) @@ -105,6 +178,11 @@ func (s *AuthService) GenerateJWTToken(ctx context.Context, user *ent.User) (str return tokenString, nil } +// RevokeJWT revokes a JWT token by its jti claim. +func (s *AuthService) RevokeJWT(jti string) { + s.TokenRevocationSvc.Revoke(jti) +} + // AuthenticateUser authenticates a user with email and password. func (s *AuthService) AuthenticateUser( ctx context.Context, @@ -121,7 +199,7 @@ func (s *AuthService) AuthenticateUser( }) if err != nil { if ent.IsNotFound(err) { - return nil, fmt.Errorf("invalid email or password: %w", ErrInvalidPassword) + return nil, fmt.Errorf("invalid email or password=[MASKED_SECRET]: %w", ErrInvalidPassword) } log.Error(ctx, "failed to get user", log.Cause(err)) @@ -167,6 +245,11 @@ func (s *AuthService) AuthenticateJWTToken(ctx context.Context, tokenString stri return nil, fmt.Errorf("%w: invalid token", ErrInvalidJWT) } + // Check revocation by jti. + if jti, ok := claims["jti"].(string); ok && s.TokenRevocationSvc.IsRevoked(jti) { + return nil, fmt.Errorf("%w: token revoked", ErrInvalidJWT) + } + userID, ok := claims["user_id"].(float64) if !ok { return nil, fmt.Errorf("%w: invalid token claims", ErrInvalidJWT) diff --git a/internal/server/biz/auth_test.go b/internal/server/biz/auth_test.go index eb34ca11b..21a9be4fc 100644 --- a/internal/server/biz/auth_test.go +++ b/internal/server/biz/auth_test.go @@ -80,7 +80,7 @@ func setupTestAuthService(t *testing.T, cacheConfig xcache.Config) (*AuthService // Create a mock system service systemService := &SystemService{ - Cache: xcache.NewFromConfig[ent.System](cacheConfig), + Cache: xcache.MustNewFromConfig[ent.System](cacheConfig), } // Set up a test secret key in the system service @@ -99,11 +99,11 @@ func setupTestAuthService(t *testing.T, cacheConfig xcache.Config) (*AuthService require.NoError(t, err) userService := &UserService{ - UserCache: xcache.NewFromConfig[ent.User](cacheConfig), + UserCache: xcache.MustNewFromConfig[ent.User](cacheConfig), } projectService := &ProjectService{ - ProjectCache: xcache.NewFromConfig[xcache.Entry[ent.Project]](cacheConfig), + ProjectCache: xcache.MustNewFromConfig[xcache.Entry[ent.Project]](cacheConfig), } apiKeyService := NewAPIKeyService(APIKeyServiceParams{ diff --git a/internal/server/biz/channel_auto_disable_test.go b/internal/server/biz/channel_auto_disable_test.go index 7f829cf10..833bfaa03 100644 --- a/internal/server/biz/channel_auto_disable_test.go +++ b/internal/server/biz/channel_auto_disable_test.go @@ -23,7 +23,7 @@ func newTestChannelService(client *ent.Client) *ChannelService { AbstractService: &AbstractService{ db: client, }, - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), } svc := &ChannelService{ diff --git a/internal/server/biz/channel_internal.go b/internal/server/biz/channel_internal.go index fb360e2d0..55cdde6ca 100644 --- a/internal/server/biz/channel_internal.go +++ b/internal/server/biz/channel_internal.go @@ -14,14 +14,22 @@ import ( // startPerformanceProcess starts the background goroutine to flush metrics to database. func (svc *ChannelService) startPerformanceProcess() { - ctx := authz.WithSystemBypass(context.Background(), "channel-record-performance") + ctx, err := authz.WithSystemBypass(context.Background(), "channel-record-performance") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } for perf := range svc.perfCh { svc.RecordPerformance(ctx, perf) } } func (svc *ChannelService) runSyncChannelModelsPeriodically(ctx context.Context) { - ctx = authz.WithSystemBypass(ctx, "channel-run-model-sync") + ctx, err := authz.WithSystemBypass(ctx, "channel-run-model-sync") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } setting := svc.SystemService.ChannelSettingOrDefault(ctx) if !svc.shouldRunModelSync(xtime.UTCNow(), setting.AutoSync.Frequency) { return @@ -59,26 +67,40 @@ func getIntervalMinutesFromAutoSyncFrequency(frequency AutoSyncFrequency) int { } func (svc *ChannelService) onCacheRefreshed(ctx context.Context, current []*Channel, lastUpdate time.Time) ([]*Channel, time.Time, bool, error) { - ctx = authz.WithSystemBypass(ctx, "channel-refresh-cache") + ctx, err := authz.WithSystemBypass(ctx, "channel-refresh-cache") + if err != nil { + return nil, time.Time{}, false, err + } return svc.reloadEnabledChannels(ctx, current, lastUpdate) } func (svc *ChannelService) onTokenRefreshed(ch *ent.Channel) func(ctx context.Context, refreshed *oauth.OAuthCredentials) error { return func(ctx context.Context, refreshed *oauth.OAuthCredentials) error { - ctx = authz.WithSystemBypass(ctx, "channel-refresh-cache") + ctx, err := authz.WithSystemBypass(ctx, "channel-refresh-cache") + if err != nil { + return err + } return svc.refreshOAuthToken(ctx, ch, refreshed) } } func (svc *ChannelService) initChannelPerformances(ctx context.Context) { - ctx = authz.WithSystemBypass(ctx, "int-channel-load-performances") + ctx, err := authz.WithSystemBypass(ctx, "int-channel-load-performances") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } if err := svc.loadChannelPerformances(ctx); err != nil { log.Warn(ctx, "failed to load channel performances", log.Cause(err)) } } func (svc *ChannelService) ReloadEnabledChannelsCache(ctx context.Context) error { - ctx = authz.WithSystemBypass(ctx, "channel-reload-enabled-channels-cache") + ctx, err := authz.WithSystemBypass(ctx, "channel-reload-enabled-channels-cache") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return err + } if err := svc.enabledChannelsCache.Load(ctx, true); err != nil { return fmt.Errorf("failed to reload enabled channels cache: %w", err) } diff --git a/internal/server/biz/channel_metrics_test.go b/internal/server/biz/channel_metrics_test.go index 9a63aca46..cf0c8a4cf 100644 --- a/internal/server/biz/channel_metrics_test.go +++ b/internal/server/biz/channel_metrics_test.go @@ -350,7 +350,7 @@ func TestChannelService_RecordPerformance(t *testing.T) { AbstractService: &AbstractService{ db: client, }, - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), }, channelPerfMetrics: make(map[int]*channelMetrics), channelErrorCounts: make(map[int]map[int]int), diff --git a/internal/server/biz/channel_probe_internal.go b/internal/server/biz/channel_probe_internal.go index 9b96892de..ebb6afc83 100644 --- a/internal/server/biz/channel_probe_internal.go +++ b/internal/server/biz/channel_probe_internal.go @@ -4,9 +4,14 @@ import ( "context" "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/log" ) func (svc *ChannelProbeService) runProbePeriodically(ctx context.Context) { - ctx = authz.WithSystemBypass(ctx, "channel_probe") + ctx, err := authz.WithSystemBypass(ctx, "channel_probe") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } svc.runProbe(ctx) } diff --git a/internal/server/biz/data_storage.go b/internal/server/biz/data_storage.go index fcd9f3520..0e226ac7b 100644 --- a/internal/server/biz/data_storage.go +++ b/internal/server/biz/data_storage.go @@ -13,6 +13,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "cloud.google.com/go/storage" "github.com/samber/lo" "github.com/spf13/afero" @@ -62,13 +64,17 @@ type DataStorageServiceParams struct { } // NewDataStorageService creates a new DataStorageService. -func NewDataStorageService(params DataStorageServiceParams) *DataStorageService { +func NewDataStorageService(params DataStorageServiceParams) (*DataStorageService, error) { + cache, err := xcache.NewFromConfig[ent.DataStorage](params.CacheConfig) + if err != nil { + return nil, err + } svc := &DataStorageService{ AbstractService: &AbstractService{ db: params.Client, }, SystemService: params.SystemService, - Cache: xcache.NewFromConfig[ent.DataStorage](params.CacheConfig), + Cache: cache, Executors: params.Executor, fsCache: make(map[int]afero.Fs), } @@ -81,7 +87,7 @@ func NewDataStorageService(params DataStorageServiceParams) *DataStorageService log.Error(context.Background(), "failed to schedule data storage filesystem refresh", log.Cause(err)) } - return svc + return svc, nil } func (s *DataStorageService) refreshFileSystems(ctx context.Context) error { @@ -389,7 +395,12 @@ func (s *DataStorageService) InvalidateFsCache(id int) error { func (s *DataStorageService) createS3Fs(ctx context.Context, s3Config *objects.S3) (afero.Fs, error) { credProvider := awscredentials.NewStaticCredentialsProvider( s3Config.AccessKey, - s3Config.SecretKey, + func() string { + if s3Config.SecretKey != nil { + return *s3Config.SecretKey + } + return "" + }(), "", ) @@ -426,13 +437,13 @@ func (s *DataStorageService) createGcsFs(ctx context.Context, gcsConfig *objects } // Create GCS client - client, err := storage.NewClient(context.Background(), googleoption.WithCredentials(creds)) + client, err := storage.NewClient(ctx, googleoption.WithCredentials(creds)) if err != nil { return nil, fmt.Errorf("failed to create GCS client: %w", err) } // Create GCS filesystem - fs, err := gcsfs.NewGcsFSFromClient(context.Background(), client) + fs, err := gcsfs.NewGcsFSFromClient(ctx, client) if err != nil { return nil, fmt.Errorf("failed to create GCS filesystem: %w", err) } @@ -531,24 +542,16 @@ func (s *DataStorageService) SaveData(ctx context.Context, ds *ent.DataStorage, } } - if ds.Type != datastorage.TypeFs { - // For S3 with PathStyle enabled, remove leading slash from key - // to avoid InvalidArgument error from S3 compatible storage services - if isS3PathStyle(ds) { - key = strings.TrimPrefix(key, "/") + if ds.Type == datastorage.TypeFs { + // Local filesystem: direct write (already atomic on most systems) + if err := afero.WriteFile(fs, key, data, 0o777); err != nil { + return fmt.Errorf("failed to write file: %w, key: %s", err, key) } - - f, err := fs.Create(key) - if err != nil { - return fmt.Errorf("failed to create file: %w, key: %s", err, key) + } else { + // S3/GCS/WebDAV: atomic write via temp-file + rename + if err := atomicWriteFile(fs, key, data); err != nil { + return fmt.Errorf("failed to atomically write file: %w, key: %s", err, key) } - - _ = f.Close() - } - - // Write data to file - if err := afero.WriteFile(fs, key, data, 0o777); err != nil { - return fmt.Errorf("failed to write file: %w, key: %s", err, key) } return nil @@ -585,23 +588,74 @@ func (s *DataStorageService) SaveDataFromReader(ctx context.Context, ds *ent.Dat key = strings.TrimPrefix(key, "/") } + var n int64 + if ds.Type == datastorage.TypeFs { f, err := fs.Create(key) if err != nil { return "", 0, fmt.Errorf("failed to create file: %w, key: %s", err, key) } - defer f.Close() - - n, err := io.Copy(f, r) + n, err = io.Copy(f, r) if err != nil { + _ = f.Close() return "", 0, fmt.Errorf("failed to write file: %w, key: %s", err, key) } + if err := f.Close(); err != nil { + return "", 0, fmt.Errorf("failed to flush file: %w, key: %s", err, key) + } + } else { + // S3/GCS/WebDAV: atomic write via temp-file + rename + var err error + n, err = atomicWriteReader(fs, key, r) + if err != nil { + return "", 0, fmt.Errorf("failed to atomically write file: %w, key: %s", err, key) + } + } - return key, n, nil + return key, n, nil default: return "", 0, fmt.Errorf("unsupported storage type: %s", ds.Type) } } +// atomicWriteFile writes data to key via a temp-file + rename pattern, +// providing at-least-once atomicity semantics for remote backends. +func atomicWriteFile(fs afero.Fs, key string, data []byte) error { + tmpKey := fmt.Sprintf(".tmp/%s-%s", uuid.New().String(), filepath.Base(key)) + if err := afero.WriteFile(fs, tmpKey, data, 0o777); err != nil { + return err + } + if err := fs.Rename(tmpKey, key); err != nil { + _ = fs.Remove(tmpKey) + return err + } + return nil +} + +// atomicWriteReader streams data from r to key via a temp-file + rename pattern, +// providing at-least-once atomicity semantics for remote backends. +func atomicWriteReader(fs afero.Fs, key string, r io.Reader) (int64, error) { + tmpKey := fmt.Sprintf(".tmp/%s-%s", uuid.New().String(), filepath.Base(key)) + f, err := fs.Create(tmpKey) + if err != nil { + return 0, err + } + n, err := io.Copy(f, r) + if err != nil { + _ = f.Close() + _ = fs.Remove(tmpKey) + return 0, err + } + if err := f.Close(); err != nil { + _ = fs.Remove(tmpKey) + return 0, err + } + if err := fs.Rename(tmpKey, key); err != nil { + _ = fs.Remove(tmpKey) + return 0, err + } + return n, nil +} + // DeleteData removes data stored under the provided key for the given data storage. // It is a no-op for database storage because the data is kept in the database itself. func (s *DataStorageService) DeleteData(ctx context.Context, ds *ent.DataStorage, key string) error { @@ -679,7 +733,7 @@ func isS3Provided(s3 *objects.S3) bool { } return s3.BucketName != "" || s3.Endpoint != "" || s3.Region != "" || - s3.AccessKey != "" || s3.SecretKey != "" + s3.AccessKey != "" || (s3.SecretKey != nil && *s3.SecretKey != "") } // isS3PathStyle checks if the data storage is S3 with PathStyle enabled. @@ -743,9 +797,11 @@ func (s *DataStorageService) mergeSettings(existing, input *objects.DataStorageS merged.S3.AccessKey = existing.S3.AccessKey } - if input.S3.SecretKey != "" { + if input.S3.SecretKey != nil { + // Explicit value provided: use it (empty string means clear the key) merged.S3.SecretKey = input.S3.SecretKey } else if existing.S3 != nil { + // No explicit value: preserve existing key merged.S3.SecretKey = existing.S3.SecretKey } } else if existing.S3 != nil { diff --git a/internal/server/biz/data_storage_test.go b/internal/server/biz/data_storage_test.go index 61ad99718..ae54fdd7d 100644 --- a/internal/server/biz/data_storage_test.go +++ b/internal/server/biz/data_storage_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/alicebob/miniredis/v2" + "github.com/samber/lo" "github.com/spf13/afero" "github.com/stretchr/testify/require" "github.com/zhenzou/executors" @@ -32,9 +33,10 @@ func setupDataStorageTest(t *testing.T) (*ent.Client, *DataStorageService, conte }, } - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: cacheConfig, }) + require.NoError(t, err) executor := executors.NewPoolScheduleExecutor(executors.WithMaxConcurrent(1)) @@ -42,12 +44,13 @@ func setupDataStorageTest(t *testing.T) (*ent.Client, *DataStorageService, conte _ = executor.Shutdown(context.Background()) }) - service := NewDataStorageService(DataStorageServiceParams{ + service, err := NewDataStorageService(DataStorageServiceParams{ SystemService: systemService, CacheConfig: cacheConfig, Executor: executor, Client: client, }) + require.NoError(t, err) ctx := context.Background() ctx = ent.NewContext(ctx, client) @@ -70,9 +73,10 @@ func setupDataStorageTestWithRedis(t *testing.T) (*ent.Client, *DataStorageServi }, } - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: cacheConfig, }) + require.NoError(t, err) executor := executors.NewPoolScheduleExecutor(executors.WithMaxConcurrent(1)) @@ -80,12 +84,13 @@ func setupDataStorageTestWithRedis(t *testing.T) (*ent.Client, *DataStorageServi _ = executor.Shutdown(context.Background()) }) - service := NewDataStorageService(DataStorageServiceParams{ + service, err := NewDataStorageService(DataStorageServiceParams{ SystemService: systemService, CacheConfig: cacheConfig, Executor: executor, Client: client, }) + require.NoError(t, err) ctx := context.Background() ctx = ent.NewContext(ctx, client) @@ -208,7 +213,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { Endpoint: "existing-endpoint", Region: "existing-region", AccessKey: existingAccess, - SecretKey: existingSecret, + SecretKey: &existingSecret, }, GCS: &objects.GCS{ BucketName: "existing-gcs-bucket", @@ -234,7 +239,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { Endpoint: "", Region: "updated-region", AccessKey: "", - SecretKey: "", + SecretKey: lo.ToPtr(""), }, GCS: &objects.GCS{ BucketName: "updated-gcs-bucket", @@ -258,7 +263,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { require.Equal(t, "", updated.Settings.S3.Endpoint) require.Equal(t, "updated-region", updated.Settings.S3.Region) require.Equal(t, existingAccess, updated.Settings.S3.AccessKey) - require.Equal(t, existingSecret, updated.Settings.S3.SecretKey) + require.Equal(t, existingSecret, *updated.Settings.S3.SecretKey) require.NotNil(t, updated.Settings.GCS) require.Equal(t, "updated-gcs-bucket", updated.Settings.GCS.BucketName) @@ -279,7 +284,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { Endpoint: "existing-endpoint", Region: "existing-region", AccessKey: "old-access", - SecretKey: "old-secret", + SecretKey: lo.ToPtr("old-secret"), }, GCS: &objects.GCS{ BucketName: "existing-gcs-bucket", @@ -307,7 +312,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { Endpoint: "new-endpoint", Region: "new-region", AccessKey: "new-access", - SecretKey: "new-secret", + SecretKey: lo.ToPtr("new-secret"), }, GCS: &objects.GCS{ BucketName: "new-gcs-bucket", @@ -331,7 +336,7 @@ func TestDataStorageService_UpdateDataStorage(t *testing.T) { require.Equal(t, "new-endpoint", updated.Settings.S3.Endpoint) require.Equal(t, "new-region", updated.Settings.S3.Region) require.Equal(t, "new-access", updated.Settings.S3.AccessKey) - require.Equal(t, "new-secret", updated.Settings.S3.SecretKey) + require.Equal(t, "new-secret", *updated.Settings.S3.SecretKey) require.NotNil(t, updated.Settings.GCS) require.Equal(t, "new-gcs-bucket", updated.Settings.GCS.BucketName) @@ -707,16 +712,18 @@ func TestDataStorageService_CacheExpiration(t *testing.T) { }, } - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: cacheConfig, }) + require.NoError(t, err) - service := NewDataStorageService(DataStorageServiceParams{ + service, err := NewDataStorageService(DataStorageServiceParams{ SystemService: systemService, CacheConfig: cacheConfig, Executor: executors.NewPoolScheduleExecutor(), Client: client, }) + require.NoError(t, err) ctx := context.Background() ctx = ent.NewContext(ctx, client) diff --git a/internal/server/biz/fx_module.go b/internal/server/biz/fx_module.go index f50bfb449..1bfa5dec5 100644 --- a/internal/server/biz/fx_module.go +++ b/internal/server/biz/fx_module.go @@ -9,6 +9,7 @@ import ( var Module = fx.Module("biz", fx.Provide(NewLiveStreamRegistry), fx.Provide(NewSystemService), + fx.Provide(NewTokenRevocationService), fx.Provide(NewWebhookNotifier), fx.Provide(NewAuthService), fx.Provide(NewChannelService), @@ -90,4 +91,16 @@ var Module = fx.Module("biz", }, }) }), + fx.Invoke(func(lc fx.Lifecycle, svc *TokenRevocationService) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + svc.StartSweeper(ctx) + return nil + }, + OnStop: func(ctx context.Context) error { + svc.Stop() + return nil + }, + }) + }), ) diff --git a/internal/server/biz/model_circuit_breaker.go b/internal/server/biz/model_circuit_breaker.go index 3fa1d23a8..15eae052d 100644 --- a/internal/server/biz/model_circuit_breaker.go +++ b/internal/server/biz/model_circuit_breaker.go @@ -93,6 +93,9 @@ type ModelCircuitBreakerStats struct { // Recovery Control NextProbeAt time.Time // Next allowed probe time (used for Open state) + // Half-Open recovery: consecutive successes needed to close the circuit. + halfOpenSuccesses int + // Probe Control (to prevent concurrent penetration) probingInProgress int32 // atomic operation probeAttempts int // number of probe attempts, used for exponential backoff @@ -158,6 +161,22 @@ func (m *ModelCircuitBreaker) RecordError(ctx context.Context, channelID int, mo now := time.Now() policy := m.GetPolicy(ctx) + // HalfOpen: any failure immediately reopens the circuit. + if stats.State == StateHalfOpen { + stats.State = StateOpen + stats.ConsecutiveFailures = policy.OpenThreshold + stats.LastFailureAt = now + stats.NextProbeAt = now.Add(policy.ProbeInterval) + stats.probeAttempts = 0 + stats.halfOpenSuccesses = 0 + + log.Warn(ctx, "model half-open failure, circuit re-opened", + log.Int("channel_id", channelID), + log.String("model_id", modelID), + ) + return + } + // 1. TTL Check: prevent zombie counts if stats.ConsecutiveFailures > 0 { if now.Sub(stats.LastFailureAt) > policy.FailureStatsTTL { @@ -208,6 +227,7 @@ func (m *ModelCircuitBreaker) RecordError(ctx context.Context, channelID int, mo } else if stats.ConsecutiveFailures >= policy.HalfOpenThreshold { if stats.State != StateHalfOpen { stats.State = StateHalfOpen + stats.halfOpenSuccesses = 0 log.Warn(ctx, "model turn to half-open due to consecutive failures", log.Int("channel_id", channelID), @@ -227,21 +247,45 @@ func (m *ModelCircuitBreaker) RecordSuccess(ctx context.Context, channelID int, stats.LastSuccessAt = time.Now() - // Reset all negative status immediately upon a single success - if stats.State != StateClosed { - log.Info(ctx, "model recovered to closed state", + switch stats.State { + case StateClosed: + // Normal operation: just reset failure counters. + stats.ConsecutiveFailures = 0 + return + + case StateHalfOpen: + // Require consecutive successes to fully recover. + stats.halfOpenSuccesses++ + policy := m.GetPolicy(ctx) + if stats.halfOpenSuccesses >= policy.HalfOpenThreshold { + stats.State = StateClosed + stats.ConsecutiveFailures = 0 + stats.halfOpenSuccesses = 0 + stats.NextProbeAt = time.Time{} + stats.probingInProgress = 0 + stats.probeAttempts = 0 + + log.Info(ctx, "model recovered to closed state from half-open", + log.Int("channel_id", channelID), + log.String("model_id", modelID), + log.Int("consecutive_successes", stats.halfOpenSuccesses), + ) + } + return + + case StateOpen: + // A success during Open (e.g. probe) transitions directly to Closed. + log.Info(ctx, "model recovered to closed state from open (probe success)", log.Int("channel_id", channelID), log.String("model_id", modelID), - log.String("previous_state", string(stats.State)), - log.Int("previous_failures", stats.ConsecutiveFailures), ) + stats.State = StateClosed + stats.ConsecutiveFailures = 0 + stats.halfOpenSuccesses = 0 + stats.NextProbeAt = time.Time{} + stats.probingInProgress = 0 + stats.probeAttempts = 0 } - - stats.State = StateClosed - stats.ConsecutiveFailures = 0 - stats.NextProbeAt = time.Time{} // Clear probe time - stats.probingInProgress = 0 // Reset probing flag - stats.probeAttempts = 0 // Reset probe count } // GetModelCircuitBreakerStats returns the current state and statistics of a model. @@ -260,6 +304,7 @@ func (m *ModelCircuitBreaker) GetModelCircuitBreakerStats(ctx context.Context, c LastFailureAt: stats.LastFailureAt, LastSuccessAt: stats.LastSuccessAt, NextProbeAt: stats.NextProbeAt, + halfOpenSuccesses: stats.halfOpenSuccesses, probeAttempts: stats.probeAttempts, probingInProgress: atomic.LoadInt32(&stats.probingInProgress), } @@ -338,6 +383,7 @@ func (m *ModelCircuitBreaker) GetAllNonClosedModels(ctx context.Context) []*Mode LastFailureAt: stats.LastFailureAt, LastSuccessAt: stats.LastSuccessAt, NextProbeAt: stats.NextProbeAt, + halfOpenSuccesses: stats.halfOpenSuccesses, probeAttempts: stats.probeAttempts, probingInProgress: atomic.LoadInt32(&stats.probingInProgress), }) @@ -368,6 +414,7 @@ func (m *ModelCircuitBreaker) GetChannelModelCircuitBreakerStats(ctx context.Con LastFailureAt: stats.LastFailureAt, LastSuccessAt: stats.LastSuccessAt, NextProbeAt: stats.NextProbeAt, + halfOpenSuccesses: stats.halfOpenSuccesses, probeAttempts: stats.probeAttempts, probingInProgress: atomic.LoadInt32(&stats.probingInProgress), }) @@ -392,6 +439,7 @@ func (m *ModelCircuitBreaker) ResetModelStatus(ctx context.Context, channelID in oldState := stats.State stats.State = StateClosed stats.ConsecutiveFailures = 0 + stats.halfOpenSuccesses = 0 stats.NextProbeAt = time.Time{} stats.probeAttempts = 0 atomic.StoreInt32(&stats.probingInProgress, 0) diff --git a/internal/server/biz/model_test.go b/internal/server/biz/model_test.go index 521a436ea..2251ca60a 100644 --- a/internal/server/biz/model_test.go +++ b/internal/server/biz/model_test.go @@ -524,7 +524,7 @@ func TestModelService_ListEnabledModels(t *testing.T) { AbstractService: &AbstractService{ db: client, }, - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), } modelSvc := &ModelService{ diff --git a/internal/server/biz/permission_validator_test.go b/internal/server/biz/permission_validator_test.go index b7abc1ad1..3144b3dc1 100644 --- a/internal/server/biz/permission_validator_test.go +++ b/internal/server/biz/permission_validator_test.go @@ -21,7 +21,7 @@ func setupTestPermissionValidator(t *testing.T) (*PermissionValidator, *ent.Clie validator := NewPermissionValidator() cacheConfig := xcache.Config{Mode: xcache.ModeMemory} userService := &UserService{ - UserCache: xcache.NewFromConfig[ent.User](cacheConfig), + UserCache: xcache.MustNewFromConfig[ent.User](cacheConfig), permissionValidator: validator, } diff --git a/internal/server/biz/project.go b/internal/server/biz/project.go index 72d4fe8d1..4289e259c 100644 --- a/internal/server/biz/project.go +++ b/internal/server/biz/project.go @@ -43,7 +43,7 @@ func NewProjectService(params ProjectServiceParams) *ProjectService { AbstractService: &AbstractService{ db: params.Ent, }, - ProjectCache: xcache.NewFromConfig[xcache.Entry[ent.Project]](params.CacheConfig), + ProjectCache: xcache.MustNewFromConfig[xcache.Entry[ent.Project]](params.CacheConfig), permissionValidator: NewPermissionValidator(), } } diff --git a/internal/server/biz/project_test.go b/internal/server/biz/project_test.go index d75757fac..33a8a2408 100644 --- a/internal/server/biz/project_test.go +++ b/internal/server/biz/project_test.go @@ -24,7 +24,7 @@ func setupTestProjectService(t *testing.T, cacheConfig xcache.Config) (*ProjectS client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") projectService := &ProjectService{ - ProjectCache: xcache.NewFromConfig[xcache.Entry[ent.Project]](cacheConfig), + ProjectCache: xcache.MustNewFromConfig[xcache.Entry[ent.Project]](cacheConfig), } return projectService, client diff --git a/internal/server/biz/prompt.go b/internal/server/biz/prompt.go index 0c96d0695..18065dfd6 100644 --- a/internal/server/biz/prompt.go +++ b/internal/server/biz/prompt.go @@ -44,7 +44,10 @@ func NewPromptService(params PromptServiceParams) *PromptService { } func (svc *PromptService) Initialize(ctx context.Context) error { - ctx = authz.WithSystemBypass(ctx, "prompt-initialize") + ctx, err := authz.WithSystemBypass(ctx, "prompt-initialize") + if err != nil { + return err + } projects, err := svc.entFromContext(ctx).Project.Query().All(ctx) if err != nil { @@ -362,7 +365,10 @@ func (svc *PromptService) BulkDisablePrompts(ctx context.Context, ids []int) err } func (svc *PromptService) loadPrompts(ctx context.Context, projectID int) error { - ctx = authz.WithSystemBypass(ctx, "prompt-load-cache") + ctx, err := authz.WithSystemBypass(ctx, "prompt-load-cache") + if err != nil { + return err + } // Check if there are updates for this project latestUpdatedPrompt, err := svc.entFromContext(ctx).Prompt.Query(). Where(prompt.ProjectID(projectID)). diff --git a/internal/server/biz/prompt_protection_rule.go b/internal/server/biz/prompt_protection_rule.go index 37a4c4d3e..f49ba6d83 100644 --- a/internal/server/biz/prompt_protection_rule.go +++ b/internal/server/biz/prompt_protection_rule.go @@ -183,7 +183,10 @@ func (svc *PromptProtectionRuleService) Stop() { } func (svc *PromptProtectionRuleService) onEnabledRulesRefreshed(ctx context.Context, _ []*ent.PromptProtectionRule, lastUpdate time.Time) ([]*ent.PromptProtectionRule, time.Time, bool, error) { - ctx = authz.WithSystemBypass(ctx, "prompt-protection-rule-cache") + ctx, err := authz.WithSystemBypass(ctx, "prompt-protection-rule-cache") + if err != nil { + return nil, time.Time{}, false, err + } client := svc.entFromContext(ctx) q := client.PromptProtectionRule.Query(). diff --git a/internal/server/biz/provider_quota.go b/internal/server/biz/provider_quota.go index adfa4be97..5eb697c5a 100644 --- a/internal/server/biz/provider_quota.go +++ b/internal/server/biz/provider_quota.go @@ -373,9 +373,11 @@ func (svc *ProviderQuotaService) saveQuotaError( existingData = map[string]any{} } - merged := lo.Assign(existingData, map[string]any{ - "error": quotaErr.Error(), - }) + merged := make(map[string]any, len(existingData)+1) + for k, v := range existingData { + merged[k] = v + } + merged["error"] = quotaErr.Error() err := svc.db.ProviderQuotaStatus.UpdateOne(existing). SetQuotaData(merged). diff --git a/internal/server/biz/provider_quota/claudecode_checker.go b/internal/server/biz/provider_quota/claudecode_checker.go index 0ee5ac1ef..dd240852c 100644 --- a/internal/server/biz/provider_quota/claudecode_checker.go +++ b/internal/server/biz/provider_quota/claudecode_checker.go @@ -47,7 +47,7 @@ func (c *ClaudeCodeQuotaChecker) CheckQuota(ctx context.Context, ch *ent.Channel } // Build HTTP request using Bearer auth like ClaudeCode transformers - httpRequest := httpclient.NewRequestBuilder(). + builder := httpclient.NewRequestBuilder(). WithMethod("POST"). WithURL(getEndpointURL(ch.BaseURL)). WithAuth(&httpclient.AuthConfig{ @@ -58,18 +58,23 @@ func (c *ClaudeCodeQuotaChecker) CheckQuota(ctx context.Context, ch *ent.Channel WithHeader("anthropic-version", claudecode.ClaudeCodeVersionHeader). WithHeader("anthropic-dangerous-direct-browser-access", claudecode.ClaudeCodeBrowserAccessHeader). WithHeader("x-app", claudecode.ClaudeCodeAppHeader). - WithHeader("content-type", "application/json"). - WithBody(map[string]any{ - "model": claudecode.ClaudeCodeQuotaCheckModel, - "messages": []map[string]any{ - { - "role": "user", - "content": "limit", - }, + WithHeader("content-type", "application/json") + + builder, err := builder.WithBody(map[string]any{ + "model": claudecode.ClaudeCodeQuotaCheckModel, + "messages": []map[string]any{ + { + "role": "user", + "content": "limit", }, - "max_tokens": 1, - }). - Build() + }, + "max_tokens": 1, + }) + if err != nil { + return QuotaData{}, fmt.Errorf("failed to build request body: %w", err) + } + + httpRequest := builder.Build() // Use proxy-configured HTTP client if available httpClient := c.httpClient diff --git a/internal/server/biz/provider_quota_internal.go b/internal/server/biz/provider_quota_internal.go index 778955af3..48437cece 100644 --- a/internal/server/biz/provider_quota_internal.go +++ b/internal/server/biz/provider_quota_internal.go @@ -4,12 +4,17 @@ import ( "context" "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/log" ) func (svc *ProviderQuotaService) runQuotaCheckScheduled(ctx context.Context) { svc.mu.Lock() defer svc.mu.Unlock() - ctx = authz.WithSystemBypass(ctx, "provider_quota") + ctx, err := authz.WithSystemBypass(ctx, "provider_quota") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } svc.runQuotaCheck(ctx, false) } diff --git a/internal/server/biz/quota.go b/internal/server/biz/quota.go index 8b078b05d..d3241245f 100644 --- a/internal/server/biz/quota.go +++ b/internal/server/biz/quota.go @@ -58,21 +58,19 @@ func (s *QuotaService) CheckAPIKeyQuota(ctx context.Context, apiKeyID int, quota return QuotaCheckResult{}, err } - if quota.Requests != nil { - reqCount, err := authz.RunWithSystemBypass(ctx, "quota-request-count", func(bypassCtx context.Context) (int64, error) { - return s.requestCount(bypassCtx, apiKeyID, window) - }) - if err != nil { - return QuotaCheckResult{}, err - } + usageAgg, err := authz.RunWithSystemBypass(ctx, "quota-usage-agg", func(bypassCtx context.Context) (usageAggResult, error) { + return s.usageAgg(bypassCtx, apiKeyID, window, quota.Requests != nil, quota.TotalTokens != nil, quota.Cost != nil) + }) + if err != nil { + return QuotaCheckResult{}, err + } - if reqCount >= *quota.Requests { - return QuotaCheckResult{ - Allowed: false, - Message: fmt.Sprintf("requests quota exceeded: %d/%d", reqCount, *quota.Requests), - Window: window, - }, nil - } + if quota.Requests != nil && usageAgg.RequestCount >= *quota.Requests { + return QuotaCheckResult{ + Allowed: false, + Message: fmt.Sprintf("requests quota exceeded: %d/%d", usageAgg.RequestCount, *quota.Requests), + Window: window, + }, nil } if quota.TotalTokens == nil && quota.Cost == nil { @@ -82,13 +80,6 @@ func (s *QuotaService) CheckAPIKeyQuota(ctx context.Context, apiKeyID int, quota }, nil } - usageAgg, err := authz.RunWithSystemBypass(ctx, "quota-usage-agg", func(bypassCtx context.Context) (usageAggResult, error) { - return s.usageAgg(bypassCtx, apiKeyID, window, quota.TotalTokens != nil, quota.Cost != nil) - }) - if err != nil { - return QuotaCheckResult{}, err - } - if quota.TotalTokens != nil && usageAgg.TotalTokens >= *quota.TotalTokens { return QuotaCheckResult{ Allowed: false, @@ -123,15 +114,8 @@ func (s *QuotaService) GetQuota(ctx context.Context, apiKeyID int, quota *object return QuotaResult{}, err } - reqCount, err := authz.RunWithSystemBypass(ctx, "quota-request-count", func(bypassCtx context.Context) (int64, error) { - return s.requestCount(bypassCtx, apiKeyID, window) - }) - if err != nil { - return QuotaResult{}, err - } - usageAgg, err := authz.RunWithSystemBypass(ctx, "quota-usage-agg", func(bypassCtx context.Context) (usageAggResult, error) { - return s.usageAgg(bypassCtx, apiKeyID, window, true, true) + return s.usageAgg(bypassCtx, apiKeyID, window, true, true, true) }) if err != nil { return QuotaResult{}, err @@ -140,7 +124,7 @@ func (s *QuotaService) GetQuota(ctx context.Context, apiKeyID int, quota *object return QuotaResult{ Window: window, Usage: QuotaUsage{ - RequestCount: reqCount, + RequestCount: usageAgg.RequestCount, TotalTokens: usageAgg.TotalTokens, TotalCost: usageAgg.TotalCost, }, @@ -232,12 +216,13 @@ func (s *QuotaService) requestCount(ctx context.Context, apiKeyID int, window Qu } type usageAggResult struct { - TotalTokens int64 - TotalCost decimal.Decimal + RequestCount int64 + TotalTokens int64 + TotalCost decimal.Decimal } -func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaWindow, needTokens bool, needCost bool) (usageAggResult, error) { - if !needTokens && !needCost { +func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaWindow, needCount bool, needTokens bool, needCost bool) (usageAggResult, error) { + if !needCount && !needTokens && !needCost { return usageAggResult{}, nil } @@ -253,14 +238,16 @@ func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaW switch { case needTokens && needCost: type row struct { - TotalTokens int64 `json:"total_tokens"` - TotalCost float64 `json:"total_cost"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` } var rows []row err := q.Modify(func(s *sql.Selector) { s.Select( + sql.As("COUNT(*)", "request_count"), sql.As(fmt.Sprintf("COALESCE(SUM(%s), 0)", s.C(usagelog.FieldTotalTokens)), "total_tokens"), sql.As(fmt.Sprintf("COALESCE(SUM(%s), 0)", s.C(usagelog.FieldTotalCost)), "total_cost"), ) @@ -274,18 +261,21 @@ func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaW } return usageAggResult{ - TotalTokens: rows[0].TotalTokens, - TotalCost: decimal.NewFromFloat(rows[0].TotalCost), + RequestCount: rows[0].RequestCount, + TotalTokens: rows[0].TotalTokens, + TotalCost: decimal.NewFromFloat(rows[0].TotalCost), }, nil case needTokens: type row struct { - TotalTokens int64 `json:"total_tokens"` + RequestCount int64 `json:"request_count"` + TotalTokens int64 `json:"total_tokens"` } var rows []row err := q.Modify(func(s *sql.Selector) { s.Select( + sql.As("COUNT(*)", "request_count"), sql.As(fmt.Sprintf("COALESCE(SUM(%s), 0)", s.C(usagelog.FieldTotalTokens)), "total_tokens"), ) }).Scan(ctx, &rows) @@ -297,16 +287,18 @@ func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaW return usageAggResult{TotalCost: decimal.Zero}, nil } - return usageAggResult{TotalTokens: rows[0].TotalTokens, TotalCost: decimal.Zero}, nil + return usageAggResult{RequestCount: rows[0].RequestCount, TotalTokens: rows[0].TotalTokens, TotalCost: decimal.Zero}, nil default: type row struct { - TotalCost float64 `json:"total_cost"` + RequestCount int64 `json:"request_count"` + TotalCost float64 `json:"total_cost"` } var rows []row err := q.Modify(func(s *sql.Selector) { s.Select( + sql.As("COUNT(*)", "request_count"), sql.As(fmt.Sprintf("COALESCE(SUM(%s), 0)", s.C(usagelog.FieldTotalCost)), "total_cost"), ) }).Scan(ctx, &rows) @@ -318,7 +310,7 @@ func (s *QuotaService) usageAgg(ctx context.Context, apiKeyID int, window QuotaW return usageAggResult{TotalCost: decimal.Zero}, nil } - return usageAggResult{TotalCost: decimal.NewFromFloat(rows[0].TotalCost)}, nil + return usageAggResult{RequestCount: rows[0].RequestCount, TotalCost: decimal.NewFromFloat(rows[0].TotalCost)}, nil } } diff --git a/internal/server/biz/quota_benchmark_test.go b/internal/server/biz/quota_benchmark_test.go index f19735a2c..45f979d59 100644 --- a/internal/server/biz/quota_benchmark_test.go +++ b/internal/server/biz/quota_benchmark_test.go @@ -69,7 +69,10 @@ func BenchmarkQuotaService_CheckAPIKeyQuota_PastDurationMinute_RequestsOnly(b *t } } - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + b.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ @@ -144,7 +147,10 @@ func BenchmarkQuotaService_CheckAPIKeyQuota_PastDurationMinute_TokensAndCost(b * } } - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + b.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ diff --git a/internal/server/biz/quota_test.go b/internal/server/biz/quota_test.go index 17f94af7e..60fca57b5 100644 --- a/internal/server/biz/quota_test.go +++ b/internal/server/biz/quota_test.go @@ -77,7 +77,10 @@ func TestQuotaService_AllTime_RequestCountExceeded(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + t.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ @@ -164,7 +167,10 @@ func TestQuotaService_PastDuration_TotalTokensExceeded(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + t.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ TotalTokens: lo.ToPtr(int64(100)), @@ -221,7 +227,10 @@ func TestQuotaService_PastDurationMinute_RequestCountExceeded(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + t.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ Requests: lo.ToPtr(int64(1)), @@ -349,7 +358,10 @@ func TestQuotaService_CalendarDay_CostExceeded(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + if err != nil { + t.Fatal(err) + } svc := NewQuotaService(client, systemService) quota := &objects.APIKeyQuota{ Cost: lo.ToPtr(decimal.NewFromFloat(10.0)), diff --git a/internal/server/biz/request.go b/internal/server/biz/request.go index 1c0ddd99b..8cb5d337c 100644 --- a/internal/server/biz/request.go +++ b/internal/server/biz/request.go @@ -45,7 +45,7 @@ func NewRequestService(ent *ent.Client, systemService *SystemService, usageLogSe UsageLogService: usageLogService, DataStorageService: dataStorageService, LiveStreamRegistry: liveStreamRegistry, - channelCache: xcache.NewFromConfig[int](xcache.Config{ + channelCache: xcache.MustNewFromConfig[int](xcache.Config{ Mode: xcache.ModeMemory, Memory: xcache.MemoryConfig{ Expiration: 30 * time.Minute, @@ -139,7 +139,7 @@ func (s *RequestService) CreateRequest( if storeRequestBody { if len(httpRequest.JSONBody) > 0 { - requestBodyBytes = httpRequest.JSONBody + requestBodyBytes = xjson.SafeJSONRawMessage(string(httpRequest.JSONBody)) } else { b, err := xjson.Marshal(httpRequest.Body) if err != nil { @@ -259,7 +259,7 @@ func (s *RequestService) CreateRequestExecution( if storeRequestBody { if len(channelRequest.JSONBody) > 0 { - requestBodyBytes = channelRequest.JSONBody + requestBodyBytes = xjson.SafeJSONRawMessage(string(channelRequest.JSONBody)) } else { b, err := xjson.Marshal(channelRequest.Body) if err != nil { diff --git a/internal/server/biz/request_internal.go b/internal/server/biz/request_internal.go index 096920013..6901acc24 100644 --- a/internal/server/biz/request_internal.go +++ b/internal/server/biz/request_internal.go @@ -8,7 +8,10 @@ import ( ) func (s *RequestService) getDataStorage(ctx context.Context, dataStorageID int) (*ent.DataStorage, error) { - ctx = authz.WithSystemBypass(ctx, "request-get-datastorage") + ctx, err := authz.WithSystemBypass(ctx, "request-get-datastorage") + if err != nil { + return nil, err + } if dataStorageID == 0 { return s.DataStorageService.GetPrimaryDataStorage(ctx) } diff --git a/internal/server/biz/request_shutdown_test.go b/internal/server/biz/request_shutdown_test.go index 6e84740b3..5bf593f3c 100644 --- a/internal/server/biz/request_shutdown_test.go +++ b/internal/server/biz/request_shutdown_test.go @@ -26,17 +26,23 @@ func setupTestRequestService(t *testing.T) (*RequestService, *ent.Client, contex ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ Ent: client, }) + if err != nil { + t.Fatal(err) + } channelService := NewChannelServiceForTest(client) usageLogService := NewUsageLogService(client, systemService, channelService) - dataStorageService := NewDataStorageService(DataStorageServiceParams{ + dataStorageService, err := NewDataStorageService(DataStorageServiceParams{ SystemService: systemService, CacheConfig: xcache.Config{}, Executor: executors.NewPoolScheduleExecutor(), Client: client, }) + if err != nil { + t.Fatal(err) + } requestService := NewRequestService(client, systemService, usageLogService, dataStorageService, NewLiveStreamRegistry()) diff --git a/internal/server/biz/role_test.go b/internal/server/biz/role_test.go index 6a729c699..3085f2658 100644 --- a/internal/server/biz/role_test.go +++ b/internal/server/biz/role_test.go @@ -23,7 +23,7 @@ func setupTestRoleService(t *testing.T) (*RoleService, *UserService, *ent.Client cacheConfig := xcache.Config{Mode: xcache.ModeMemory} userService := &UserService{ - UserCache: xcache.NewFromConfig[ent.User](cacheConfig), + UserCache: xcache.MustNewFromConfig[ent.User](cacheConfig), permissionValidator: NewPermissionValidator(), } diff --git a/internal/server/biz/system.go b/internal/server/biz/system.go index 61edf23bb..0e7d5dacf 100644 --- a/internal/server/biz/system.go +++ b/internal/server/biz/system.go @@ -219,13 +219,13 @@ type WebhookNotifierConfig struct { } type WebhookTarget struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - URL string `json:"url"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + URL string `json:"url"` Proxy *httpclient.ProxyConfig `json:"proxy,omitempty"` - TimeoutMs int `json:"timeout_ms"` - Headers []objects.HeaderEntry `json:"headers"` - Body string `json:"body"` + TimeoutMs int `json:"timeout_ms"` + Headers []objects.HeaderEntry `json:"headers"` + Body string `json:"body"` } type WebhookSubscription struct { @@ -433,14 +433,18 @@ type SystemServiceParams struct { Ent *ent.Client } -func NewSystemService(params SystemServiceParams) *SystemService { +func NewSystemService(params SystemServiceParams) (*SystemService, error) { + cache, err := xcache.NewFromConfig[ent.System](params.CacheConfig) + if err != nil { + return nil, err + } return &SystemService{ AbstractService: &AbstractService{ db: params.Ent, }, CacheConfig: params.CacheConfig, - Cache: xcache.NewFromConfig[ent.System](params.CacheConfig), - } + Cache: cache, + }, nil } type SystemService struct { @@ -451,10 +455,16 @@ type SystemService struct { mu sync.RWMutex timeLocation *time.Location + + initOnce sync.Once + initErr error } func (s *SystemService) IsInitialized(ctx context.Context) (bool, error) { - ctx = authz.WithSystemBypass(ctx, "system-is-initialized") + ctx, err := authz.WithSystemBypass(ctx, "system-is-initialized") + if err != nil { + return false, err + } client := s.entFromContext(ctx) sys, err := client.System.Query().Where(system.KeyEQ(SystemKeyInitialized)).Only(ctx) @@ -480,129 +490,148 @@ type InitializeSystemParams struct { // Initialize initializes the system with a secret key and sets the initialized flag. func (s *SystemService) Initialize(ctx context.Context, params *InitializeSystemParams) (err error) { - ctx = authz.WithSystemBypass(ctx, "system-initialize") - // Check if system is already initialized - isInitialized, err := s.IsInitialized(ctx) - if err != nil { - return fmt.Errorf("failed to check initialization status: %w", err) - } + s.initOnce.Do(func() { + ctx, err = authz.WithSystemBypass(ctx, "system-initialize") + if err != nil { + s.initErr = err + return + } + // Check if system is already initialized + isInitialized, err := s.IsInitialized(ctx) + if err != nil { + s.initErr = fmt.Errorf("failed to check initialization status: %w", err) + return + } - if isInitialized { - // System is already initialized, nothing to do - return nil - } + if isInitialized { + // System is already initialized, nothing to do + return + } - secretKey, err := GenerateSecretKey() - if err != nil { - return fmt.Errorf("failed to generate secret key: %w", err) - } + secretKey, err := GenerateSecretKey() + if err != nil { + s.initErr = fmt.Errorf("failed to generate secret key: %w", err) + return + } - db := s.entFromContext(ctx) + db := s.entFromContext(ctx) - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to start transaction: %w", err) - } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + s.initErr = fmt.Errorf("failed to start transaction: %w", err) + return + } + + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() - defer func() { + ctx = ent.NewContext(ctx, tx.Client()) + + hashedPassword, err := HashPassword(params.OwnerPassword) if err != nil { - _ = tx.Rollback() + s.initErr = fmt.Errorf("failed to hash password=[MASKED_SECRET]: %w", err) + return } - }() - ctx = ent.NewContext(ctx, tx.Client()) + // Create owner user. + preferLanguage := params.PreferLanguage + if preferLanguage == "" { + preferLanguage = "en" // Default to English if not specified + } + user, err := tx.User.Create(). + SetEmail(params.OwnerEmail). + SetPassword(hashedPassword). + SetFirstName(params.OwnerFirstName). + SetLastName(params.OwnerLastName). + SetPreferLanguage(preferLanguage). + SetIsOwner(true). + SetScopes([]string{"*"}). // Give owner all scopes + Save(ctx) + if err != nil { + s.initErr = fmt.Errorf("failed to create owner user: %w", err) + return + } - hashedPassword, err := HashPassword(params.OwnerPassword) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Create owner user. - preferLanguage := params.PreferLanguage - if preferLanguage == "" { - preferLanguage = "en" // Default to English if not specified - } - user, err := tx.User.Create(). - SetEmail(params.OwnerEmail). - SetPassword(hashedPassword). - SetFirstName(params.OwnerFirstName). - SetLastName(params.OwnerLastName). - SetPreferLanguage(preferLanguage). - SetIsOwner(true). - SetScopes([]string{"*"}). // Give owner all scopes - Save(ctx) - if err != nil { - return fmt.Errorf("failed to create owner user: %w", err) - } + log.Info(ctx, "created owner user", zap.Int("user_id", user.ID)) - log.Info(ctx, "created owner user", zap.Int("user_id", user.ID)) + // Set user in context for project creation + ctx = contexts.WithUser(ctx, user) + // Create default project and assign owner + projectService := NewProjectService(ProjectServiceParams{}) + projectInput := ent.CreateProjectInput{ + Name: "Default", + Description: lo.ToPtr("Default project"), + } - // Set user in context for project creation - ctx = contexts.WithUser(ctx, user) - // Create default project and assign owner - projectService := NewProjectService(ProjectServiceParams{}) - projectInput := ent.CreateProjectInput{ - Name: "Default", - Description: lo.ToPtr("Default project"), - } + _, err = projectService.CreateProject(ctx, projectInput) + if err != nil { + s.initErr = fmt.Errorf("failed to create default project: %w", err) + return + } - _, err = projectService.CreateProject(ctx, projectInput) - if err != nil { - return fmt.Errorf("failed to create default project: %w", err) - } + log.Info(ctx, "created default project", zap.String("slug", "default")) - log.Info(ctx, "created default project", zap.String("slug", "default")) + // Set secret key. + err = s.setSystemValue(ctx, SystemKeySecretKey, secretKey) + if err != nil { + s.initErr = fmt.Errorf("failed to set secret key: %w", err) + return + } - // Set secret key. - err = s.setSystemValue(ctx, SystemKeySecretKey, secretKey) - if err != nil { - return fmt.Errorf("failed to set secret key: %w", err) - } + // Set brand name. + err = s.setSystemValue(ctx, SystemKeyBrandName, params.BrandName) + if err != nil { + s.initErr = fmt.Errorf("failed to set brand name: %w", err) + return + } - // Set brand name. - err = s.setSystemValue(ctx, SystemKeyBrandName, params.BrandName) - if err != nil { - return fmt.Errorf("failed to set brand name: %w", err) - } - - // Create primary data storage - primaryDataStorage, err := tx.DataStorage.Create(). - SetName("Primary"). - SetDescription("Primary database storage"). - SetPrimary(true). - SetType("database"). - SetSettings(&objects.DataStorageSettings{}). - SetStatus("active"). - Save(ctx) - if err != nil { - return fmt.Errorf("failed to create primary data storage: %w", err) - } + // Create primary data storage + primaryDataStorage, err := tx.DataStorage.Create(). + SetName("Primary"). + SetDescription("Primary database storage"). + SetPrimary(true). + SetType("database"). + SetSettings(&objects.DataStorageSettings{}). + SetStatus("active"). + Save(ctx) + if err != nil { + s.initErr = fmt.Errorf("failed to create primary data storage: %w", err) + return + } - // Set default data storage ID. - err = s.SetDefaultDataStorageID(ctx, primaryDataStorage.ID) - if err != nil { - return fmt.Errorf("failed to set default data storage ID: %w", err) - } + // Set default data storage ID. + err = s.SetDefaultDataStorageID(ctx, primaryDataStorage.ID) + if err != nil { + s.initErr = fmt.Errorf("failed to set default data storage ID: %w", err) + return + } - log.Info(ctx, "created primary data storage", zap.Int("data_storage_id", primaryDataStorage.ID)) + log.Info(ctx, "created primary data storage", zap.Int("data_storage_id", primaryDataStorage.ID)) - // Set initialized flag to true. - err = s.setSystemValue(ctx, SystemKeyInitialized, "true") - if err != nil { - return fmt.Errorf("failed to set initialized flag: %w", err) - } + // Set initialized flag to true. + err = s.setSystemValue(ctx, SystemKeyInitialized, "true") + if err != nil { + s.initErr = fmt.Errorf("failed to set initialized flag: %w", err) + return + } - // Record current build version for initialized system. - err = s.SetVersion(ctx, build.Version) - if err != nil { - return fmt.Errorf("failed to set system version: %w", err) - } + // Record current build version for initialized system. + err = s.SetVersion(ctx, build.Version) + if err != nil { + s.initErr = fmt.Errorf("failed to set system version: %w", err) + return + } - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } + if err := tx.Commit(); err != nil { + s.initErr = fmt.Errorf("failed to commit transaction: %w", err) + return + } + }) - return nil + return s.initErr } // SecretKey retrieves the JWT secret key from system settings. @@ -636,7 +665,10 @@ func (s *SystemService) StoreChunks(ctx context.Context) (bool, error) { // BrandName retrieves the brand name. func (s *SystemService) BrandName(ctx context.Context) (string, error) { - ctx = authz.WithSystemBypass(ctx, "system-brand-name") + ctx, err := authz.WithSystemBypass(ctx, "system-brand-name") + if err != nil { + return "", err + } client := s.entFromContext(ctx) sys, err := client.System.Query().Where(system.KeyEQ(SystemKeyBrandName)).Only(ctx) @@ -658,7 +690,10 @@ func (s *SystemService) SetBrandName(ctx context.Context, brandName string) erro // BrandLogo retrieves the brand logo (base64 encoded). func (s *SystemService) BrandLogo(ctx context.Context) (string, error) { - ctx = authz.WithSystemBypass(ctx, "system-brand-logo") + ctx, err := authz.WithSystemBypass(ctx, "system-brand-logo") + if err != nil { + return "", err + } client := s.entFromContext(ctx) sys, err := client.System.Query().Where(system.KeyEQ(SystemKeyBrandLogo)).Only(ctx) @@ -1255,3 +1290,16 @@ func (s *SystemService) UpdateAutoBackupLastRun(ctx context.Context, lastError s return s.SetAutoBackupSettings(ctx, *settings) } + +// UpdateAutoBackupError records only the error from a failed backup attempt, +// without touching LastBackupAt (which should only advance on success). +func (s *SystemService) UpdateAutoBackupError(ctx context.Context, errMsg string) error { + settings, err := s.AutoBackupSettings(ctx) + if err != nil { + return err + } + + settings.LastBackupError = errMsg + + return s.SetAutoBackupSettings(ctx, *settings) +} diff --git a/internal/server/biz/system_integration_test.go b/internal/server/biz/system_integration_test.go index 8f817ae09..73b89ce46 100644 --- a/internal/server/biz/system_integration_test.go +++ b/internal/server/biz/system_integration_test.go @@ -22,7 +22,8 @@ func TestSystemService_Initialize(t *testing.T) { err := migrator.Run(ctx) require.NoError(t, err) - service := biz.NewSystemService(biz.SystemServiceParams{}) + service, err := biz.NewSystemService(biz.SystemServiceParams{}) + require.NoError(t, err) // Test system initialization with auto-generated secret key err = service.Initialize(ctx, &biz.InitializeSystemParams{ diff --git a/internal/server/biz/system_onboarding.go b/internal/server/biz/system_onboarding.go index 92a10bcb8..77e49d3ea 100644 --- a/internal/server/biz/system_onboarding.go +++ b/internal/server/biz/system_onboarding.go @@ -29,7 +29,10 @@ type OnboardingRecord struct { // OnboardingInfo retrieves the onboarding information from system settings. // Returns nil if not set. func (s *SystemService) OnboardingInfo(ctx context.Context) (*OnboardingRecord, error) { - ctx = authz.WithSystemBypass(ctx, "read-onboarding-info") + ctx, err := authz.WithSystemBypass(ctx, "read-onboarding-info") + if err != nil { + return nil, err + } value, err := s.getSystemValue(ctx, SystemKeyOnboarded) if err != nil { diff --git a/internal/server/biz/system_onboarding_test.go b/internal/server/biz/system_onboarding_test.go index 5f4ca76fe..c841e4348 100644 --- a/internal/server/biz/system_onboarding_test.go +++ b/internal/server/biz/system_onboarding_test.go @@ -15,7 +15,10 @@ func TestSystemService_OnboardingInfo_NotSet(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) @@ -30,13 +33,16 @@ func TestSystemService_OnboardingInfo_InvalidJSON(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // Manually insert invalid JSON - _, err := client.System.Create(). + _, err = client.System.Create(). SetKey(SystemKeyOnboarded). SetValue("invalid-json"). Save(ctx) @@ -52,7 +58,10 @@ func TestSystemService_SetOnboardingInfo(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) @@ -62,7 +71,7 @@ func TestSystemService_SetOnboardingInfo(t *testing.T) { Onboarded: true, } - err := service.SetOnboardingInfo(ctx, info) + err = service.SetOnboardingInfo(ctx, info) require.NoError(t, err) // Verify it was saved correctly @@ -76,13 +85,16 @@ func TestSystemService_CompleteOnboarding_FirstTime(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // Complete onboarding for the first time - err := service.CompleteOnboarding(ctx) + err = service.CompleteOnboarding(ctx) require.NoError(t, err) // Verify onboarding is completed @@ -102,13 +114,16 @@ func TestSystemService_CompleteOnboarding_PreservesExistingModules(t *testing.T) client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // First, complete system model setting onboarding - err := service.CompleteSystemModelSettingOnboarding(ctx) + err = service.CompleteSystemModelSettingOnboarding(ctx) require.NoError(t, err) // Then complete main onboarding @@ -130,13 +145,16 @@ func TestSystemService_CompleteOnboarding_DoesNotOverwriteAutoDisableChannel(t * client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // First complete main onboarding (this sets AutoDisableChannel) - err := service.CompleteOnboarding(ctx) + err = service.CompleteOnboarding(ctx) require.NoError(t, err) info1, err := service.OnboardingInfo(ctx) @@ -159,13 +177,16 @@ func TestSystemService_CompleteSystemModelSettingOnboarding_FirstTime(t *testing client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // Complete system model setting onboarding - err := service.CompleteSystemModelSettingOnboarding(ctx) + err = service.CompleteSystemModelSettingOnboarding(ctx) require.NoError(t, err) // Verify it's completed @@ -181,13 +202,16 @@ func TestSystemService_CompleteSystemModelSettingOnboarding_PreservesOtherFields client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // First complete main onboarding - err := service.CompleteOnboarding(ctx) + err = service.CompleteOnboarding(ctx) require.NoError(t, err) // Then complete system model setting onboarding @@ -208,13 +232,16 @@ func TestSystemService_CompleteAutoDisableChannelOnboarding_FirstTime(t *testing client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // Complete auto-disable channel onboarding - err := service.CompleteAutoDisableChannelOnboarding(ctx) + err = service.CompleteAutoDisableChannelOnboarding(ctx) require.NoError(t, err) // Verify it's completed @@ -230,13 +257,16 @@ func TestSystemService_CompleteAutoDisableChannelOnboarding_PreservesOtherFields client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // First complete main onboarding - err := service.CompleteOnboarding(ctx) + err = service.CompleteOnboarding(ctx) require.NoError(t, err) // Then complete system model setting onboarding @@ -263,7 +293,10 @@ func TestSystemService_OnboardingInfo_FullWorkflow(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) @@ -314,7 +347,10 @@ func TestSystemService_OnboardingInfo_JSONSerialization(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + if err != nil { + t.Fatal(err) + } ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) diff --git a/internal/server/biz/system_proxy.go b/internal/server/biz/system_proxy.go index 47537266f..cdb31bc60 100644 --- a/internal/server/biz/system_proxy.go +++ b/internal/server/biz/system_proxy.go @@ -2,8 +2,16 @@ package biz import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" + "io" + "os" + "strings" "github.com/looplj/axonhub/internal/ent" ) @@ -12,6 +20,9 @@ const ( // SystemKeyProxyPresets is the key used to store proxy preset configurations. // The value is JSON-encoded []ProxyPreset. SystemKeyProxyPresets = "system_proxy_presets" + + // envSecretKey is the environment variable used to derive the encryption key. + envSecretKey = "AXONHUB_SECRET" ) // ProxyPreset represents a proxy configuration preset. @@ -22,7 +33,80 @@ type ProxyPreset struct { Password string `json:"password,omitempty"` } -// ProxyPresets retrieves all proxy presets. +// encryptPassword encrypts a password using AES-GCM with a key derived from AXONHUB_SECRET. +// Returns a REDACTED marker if no secret is configured (TODO: proper encryption). +func encryptPassword(plaintext string) (string, error) { + secret := os.Getenv(envSecretKey) + if secret == "" { + // No secret configured — cannot encrypt. Flag with a marker. + return "**REDACTED**", nil + } + + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decryptPassword decrypts an AES-GCM encrypted password. +// Returns the input unchanged if it doesn't look encrypted (legacy or redacted values). +func decryptPassword(encoded string) string { + if encoded == "" || strings.HasPrefix(encoded, "**REDACTED**") { + return encoded + } + + secret := os.Getenv(envSecretKey) + if secret == "" { + return encoded + } + + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + // Not base64 — assume plaintext or redacted. + return encoded + } + + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return encoded + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return encoded + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return encoded + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + // Decryption failed — likely wrong key or not encrypted data. + return encoded + } + + return string(plaintext) +} + +// ProxyPresets retrieves all proxy presets, decrypting passwords on read. func (s *SystemService) ProxyPresets(ctx context.Context) ([]ProxyPreset, error) { value, err := s.getSystemValue(ctx, SystemKeyProxyPresets) if err != nil { @@ -38,10 +122,16 @@ func (s *SystemService) ProxyPresets(ctx context.Context) ([]ProxyPreset, error) return nil, fmt.Errorf("failed to unmarshal proxy presets: %w", err) } + // F-D92: Decrypt passwords on read. + for i := range presets { + presets[i].Password = decryptPassword(presets[i].Password) + } + return presets, nil } // SaveProxyPreset adds or updates a proxy preset, deduplicating by URL. +// F-D92: Encrypts passwords before storage. func (s *SystemService) SaveProxyPreset(ctx context.Context, preset ProxyPreset) error { presets, err := s.ProxyPresets(ctx) if err != nil { @@ -52,6 +142,12 @@ func (s *SystemService) SaveProxyPreset(ctx context.Context, preset ProxyPreset) for i, p := range presets { if p.URL == preset.URL { + // Encrypt the new password before storing. + encryptedPw, err := encryptPassword(preset.Password) + if err != nil { + return fmt.Errorf("failed to encrypt proxy password: %w", err) + } + preset.Password = encryptedPw presets[i] = preset found = true @@ -60,10 +156,16 @@ func (s *SystemService) SaveProxyPreset(ctx context.Context, preset ProxyPreset) } if !found { + // Encrypt the new password before storing. + encryptedPw, err := encryptPassword(preset.Password) + if err != nil { + return fmt.Errorf("failed to encrypt proxy password: %w", err) + } + preset.Password = encryptedPw presets = append(presets, preset) } - jsonBytes, err := json.Marshal(presets) //nolint:gosec // G117: Password field is stored internally, not exposed to API responses + jsonBytes, err := json.Marshal(presets) if err != nil { return fmt.Errorf("failed to marshal proxy presets: %w", err) } @@ -85,7 +187,7 @@ func (s *SystemService) DeleteProxyPreset(ctx context.Context, url string) error } } - jsonBytes, err := json.Marshal(filtered) //nolint:gosec // G117: Password field is stored internally, not exposed to API responses + jsonBytes, err := json.Marshal(filtered) if err != nil { return fmt.Errorf("failed to marshal proxy presets: %w", err) } diff --git a/internal/server/biz/system_test.go b/internal/server/biz/system_test.go index 4aac1eba5..c140a8562 100644 --- a/internal/server/biz/system_test.go +++ b/internal/server/biz/system_test.go @@ -22,7 +22,8 @@ func TestSystemService_GetSecretKey_NotInitialized(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := t.Context() ctx = ent.NewContext(ctx, client) @@ -40,7 +41,7 @@ func setupTestSystemService(t *testing.T, cacheConfig xcache.Config) (*SystemSer client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") systemService := &SystemService{ - Cache: xcache.NewFromConfig[ent.System](cacheConfig), + Cache: xcache.MustNewFromConfig[ent.System](cacheConfig), } return systemService, client @@ -688,12 +689,13 @@ func TestSystemService_Initialize_DataMigrationIdempotency(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := t.Context() ctx = ent.NewContext(ctx, client) // First initialization - err := service.Initialize(ctx, &InitializeSystemParams{ + err = service.Initialize(ctx, &InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -733,12 +735,13 @@ func TestSystemService_Initialize_CreatesDefaultProject(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := t.Context() ctx = ent.NewContext(ctx, client) // Initialize system - err := service.Initialize(ctx, &InitializeSystemParams{ + err = service.Initialize(ctx, &InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -769,12 +772,13 @@ func TestSystemService_Initialize_SetsAllSystemKeys(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := t.Context() ctx = ent.NewContext(ctx, client) // Initialize system - err := service.Initialize(ctx, &InitializeSystemParams{ + err = service.Initialize(ctx, &InitializeSystemParams{ OwnerEmail: "owner@example.com", OwnerPassword: "password123", OwnerFirstName: "System", @@ -815,13 +819,15 @@ func TestSystemService_DefaultDataStorageID(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := context.Background() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // Test getting default data storage ID when not set (should return 0) - defaultID, err := service.DefaultDataStorageID(ctx) + var defaultID int + defaultID, err = service.DefaultDataStorageID(ctx) require.NoError(t, err) require.Equal(t, 0, defaultID) @@ -850,13 +856,14 @@ func TestSystemService_Initialize_TransactionRollback(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") defer client.Close() - service := NewSystemService(SystemServiceParams{}) + service, err := NewSystemService(SystemServiceParams{}) + require.NoError(t, err) ctx := t.Context() ctx = ent.NewContext(ctx, client) ctx = authz.WithTestBypass(ctx) // First, create a user with the same email to cause a constraint violation - _, err := client.User.Create(). + _, err = client.User.Create(). SetEmail("owner@example.com"). SetPassword("hashedpassword"). SetFirstName("Existing"). diff --git a/internal/server/biz/test_helper.go b/internal/server/biz/test_helper.go index 7c34773c4..848be4938 100644 --- a/internal/server/biz/test_helper.go +++ b/internal/server/biz/test_helper.go @@ -13,7 +13,7 @@ func NewChannelServiceForTest(client *ent.Client) *ChannelService { AbstractService: &AbstractService{ db: client, }, - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), } svc := NewChannelService(ChannelServiceParams{ diff --git a/internal/server/biz/thread_test.go b/internal/server/biz/thread_test.go index 13446976f..9e01fd762 100644 --- a/internal/server/biz/thread_test.go +++ b/internal/server/biz/thread_test.go @@ -18,10 +18,20 @@ func setupTestThreadService(t *testing.T) (*ThreadService, *ent.Client) { t.Helper() client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) + require.NoError(t, err) + dataStorageService, err := NewDataStorageService( + DataStorageServiceParams{ + SystemService: systemService, + CacheConfig: xcache.Config{}, + Executor: executors.NewPoolScheduleExecutor(), + Client: client, + }, + ) + require.NoError(t, err) threadService := NewThreadService( client, NewTraceService(TraceServiceParams{ @@ -29,14 +39,7 @@ func setupTestThreadService(t *testing.T) (*ThreadService, *ent.Client) { client, systemService, NewUsageLogService(client, systemService, NewChannelServiceForTest(client)), - NewDataStorageService( - DataStorageServiceParams{ - SystemService: systemService, - CacheConfig: xcache.Config{}, - Executor: executors.NewPoolScheduleExecutor(), - Client: client, - }, - ), + dataStorageService, NewLiveStreamRegistry(), ), Ent: client, diff --git a/internal/server/biz/trace_test.go b/internal/server/biz/trace_test.go index 404907443..b8e78b5bb 100644 --- a/internal/server/biz/trace_test.go +++ b/internal/server/biz/trace_test.go @@ -39,11 +39,12 @@ func setupTestTraceService(t *testing.T, client *ent.Client) (*TraceService, *en require.NoError(t, err) } - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) - dataStorageService := NewDataStorageService( + require.NoError(t, err) + dataStorageService, err := NewDataStorageService( DataStorageServiceParams{ SystemService: systemService, CacheConfig: xcache.Config{}, @@ -51,6 +52,7 @@ func setupTestTraceService(t *testing.T, client *ent.Client) (*TraceService, *en Client: client, }, ) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) usageLogService := NewUsageLogService(client, systemService, channelService) traceService := NewTraceService(TraceServiceParams{ diff --git a/internal/server/biz/usage_cost_test.go b/internal/server/biz/usage_cost_test.go index fae1acf0f..33bb98895 100644 --- a/internal/server/biz/usage_cost_test.go +++ b/internal/server/biz/usage_cost_test.go @@ -83,7 +83,8 @@ func TestUsageCost_PerUnitPromptAndCompletion(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -158,7 +159,8 @@ func TestUsageCost_TieredPrompt(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -211,7 +213,8 @@ func TestUsageCost_NoPriceConfigured(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -290,7 +293,8 @@ func TestUsageCost_CacheVariant5Min(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -381,7 +385,8 @@ func TestUsageCost_CacheVariant1Hour(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -477,7 +482,8 @@ func TestUsageCost_CacheVariantBoth5MinAnd1Hour(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) @@ -564,7 +570,8 @@ func TestUsageCost_CacheVariantFallbackToShared(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{Ent: client}) + systemService, err := NewSystemService(SystemServiceParams{Ent: client}) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) built, err := channelService.GetChannel(ctx, ch.ID) require.NoError(t, err) diff --git a/internal/server/biz/usage_log_test.go b/internal/server/biz/usage_log_test.go index 5077e2d21..a3a92ae5c 100644 --- a/internal/server/biz/usage_log_test.go +++ b/internal/server/biz/usage_log_test.go @@ -40,10 +40,11 @@ func TestUsageLogService_CreateUsageLog_PromptWriteCachedTokens(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) svc := NewUsageLogService(client, systemService, channelService) @@ -136,10 +137,11 @@ func TestUsageLogService_CreateUsageLog_WithPriceReferenceID(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) // Preload the channel with model prices @@ -259,10 +261,11 @@ func TestUsageLogService_CreateUsageLog_WithCachedTokens(t *testing.T) { Save(ctx) require.NoError(t, err) - systemService := NewSystemService(SystemServiceParams{ + systemService, err := NewSystemService(SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) + require.NoError(t, err) channelService := NewChannelServiceForTest(client) // Preload the channel with model prices diff --git a/internal/server/biz/user.go b/internal/server/biz/user.go index 4de95f8fa..2003fc1fd 100644 --- a/internal/server/biz/user.go +++ b/internal/server/biz/user.go @@ -37,7 +37,7 @@ func NewUserService(params UserServiceParams) *UserService { AbstractService: &AbstractService{ db: params.Ent, }, - UserCache: xcache.NewFromConfig[ent.User](params.CacheConfig), + UserCache: xcache.MustNewFromConfig[ent.User](params.CacheConfig), permissionValidator: NewPermissionValidator(), } } diff --git a/internal/server/biz/user_test.go b/internal/server/biz/user_test.go index 040f3747c..745293c97 100644 --- a/internal/server/biz/user_test.go +++ b/internal/server/biz/user_test.go @@ -22,7 +22,7 @@ func setupTestUserService(t *testing.T) (*UserService, *ent.Client) { cacheConfig := xcache.Config{Mode: xcache.ModeMemory} userService := &UserService{ - UserCache: xcache.NewFromConfig[ent.User](cacheConfig), + UserCache: xcache.MustNewFromConfig[ent.User](cacheConfig), permissionValidator: NewPermissionValidator(), } diff --git a/internal/server/biz/webhook_notifier.go b/internal/server/biz/webhook_notifier.go index 2bb59d93b..aa9035b67 100644 --- a/internal/server/biz/webhook_notifier.go +++ b/internal/server/biz/webhook_notifier.go @@ -94,7 +94,11 @@ func (n *WebhookNotifier) NotifyChannelAutoDisabled(ctx context.Context, event C } func (n *WebhookNotifier) notify(ctx context.Context, eventName string, renderCtx WebhookRenderContext) { - ctx = authz.WithSystemBypass(context.WithoutCancel(ctx), "webhook-notifier") + ctx, err := authz.WithSystemBypass(context.WithoutCancel(ctx), "webhook-notifier") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } cfg := *n.SystemService.WebhookNotifierConfigOrDefault(ctx) targets := n.selectTargets(cfg, eventName) diff --git a/internal/server/biz/webhook_notifier_test.go b/internal/server/biz/webhook_notifier_test.go index 66180c849..9972110a2 100644 --- a/internal/server/biz/webhook_notifier_test.go +++ b/internal/server/biz/webhook_notifier_test.go @@ -25,7 +25,7 @@ func newTestSystemServiceWithWebhookConfig(t *testing.T, client *ent.Client, cfg AbstractService: &AbstractService{ db: client, }, - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), } ctx := ent.NewContext(context.Background(), client) diff --git a/internal/server/db/ent.go b/internal/server/db/ent.go index 1d26dd6df..16fda1083 100644 --- a/internal/server/db/ent.go +++ b/internal/server/db/ent.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "time" "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql/schema" @@ -21,7 +22,7 @@ import ( _ "github.com/looplj/axonhub/internal/pkg/sqlite" ) -func NewEntClient(cfg Config) *ent.Client { +func NewEntClient(cfg Config, ctx context.Context) *ent.Client { var opts []ent.Option if cfg.Debug { opts = append(opts, ent.Debug()) @@ -79,8 +80,11 @@ func NewEntClient(cfg Config) *ent.Client { opts = append(opts, ent.Driver(drv)) client := ent.NewClient(opts...) + migCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + err = client.Schema.Create( - context.Background(), + migCtx, migrate.WithGlobalUniqueID(false), migrate.WithForeignKeys(false), migrate.WithDropIndex(true), @@ -92,10 +96,8 @@ func NewEntClient(cfg Config) *ent.Client { } // Run data migrations using the Migrator framework - ctx := context.Background() - migrator := datamigrate.NewMigrator(client) - if err := migrator.Run(ctx); err != nil { + if err := migrator.Run(migCtx); err != nil { panic(err) } diff --git a/internal/server/dependencies/fx_module.go b/internal/server/dependencies/fx_module.go index 47cb5cdde..f072ba4e6 100644 --- a/internal/server/dependencies/fx_module.go +++ b/internal/server/dependencies/fx_module.go @@ -6,6 +6,7 @@ import ( "github.com/zhenzou/executors" "go.uber.org/fx" + "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/log" "github.com/looplj/axonhub/internal/server/db" "github.com/looplj/axonhub/llm/httpclient" @@ -23,7 +24,9 @@ func NewHttpClient(params NewHttpClientParams) *httpclient.HttpClient { var Module = fx.Module("dependencies", fx.Provide(log.New), - fx.Provide(db.NewEntClient), + fx.Provide(func(cfg db.Config) *ent.Client { + return db.NewEntClient(cfg, context.Background()) + }), fx.Provide(NewHttpClient), fx.Provide(NewExecutors), fx.Invoke(func(lc fx.Lifecycle, executor executors.ScheduledExecutor) { diff --git a/internal/server/gc/gc.go b/internal/server/gc/gc.go index 4ebf1412b..b7ed37c93 100644 --- a/internal/server/gc/gc.go +++ b/internal/server/gc/gc.go @@ -3,6 +3,8 @@ package gc import ( "context" "fmt" + "sync" + "strings" "time" "entgo.io/ent/dialect" @@ -15,7 +17,6 @@ import ( "github.com/looplj/axonhub/internal/ent/channelprobe" "github.com/looplj/axonhub/internal/ent/request" "github.com/looplj/axonhub/internal/ent/requestexecution" - "github.com/looplj/axonhub/internal/ent/schema/schematype" "github.com/looplj/axonhub/internal/ent/thread" "github.com/looplj/axonhub/internal/ent/trace" "github.com/looplj/axonhub/internal/ent/usagelog" @@ -33,6 +34,18 @@ type Config struct { VacuumFull bool `json:"vacuum_full" yaml:"vacuum_full" conf:"vacuum_full"` } +// Worker handles garbage collection and cleanup operations. + +// CleanupStats holds the results of a cleanup operation. +type CleanupStats struct { + RequestExecsDeleted int + RequestsDeleted int + ThreadsDeleted int + TracesDeleted int + UsageLogsDeleted int + ChannelProbesDeleted int +} + // Worker handles garbage collection and cleanup operations. type Worker struct { SystemService *biz.SystemService @@ -41,6 +54,9 @@ type Worker struct { Ent *ent.Client Config Config CancelFunc context.CancelFunc + wg sync.WaitGroup // F-D34: tracks in-flight cleanup tasks + mu sync.Mutex // protects cleanupFailures + cleanupFailures []string // F-D7: tracks external storage deletion failures } type Params struct { @@ -119,19 +135,24 @@ func (w *Worker) Start(ctx context.Context) error { } func (w *Worker) Stop(ctx context.Context) error { + // F-D34: Cancel first to stop scheduling new tasks, + // then wait for all in-flight cleanup tasks to finish. if w.CancelFunc != nil { w.CancelFunc() } + w.wg.Wait() + return w.Executor.Shutdown(ctx) } -// runCleanup executes the cleanup process based on storage policy. -func (w *Worker) runCleanup(ctx context.Context, manual bool) { +// runCleanup executes the cleanup process based on storage policy, accumulating stats. +func (w *Worker) runCleanup(ctx context.Context, manual bool, stats *CleanupStats) { log.Info(ctx, "Starting automatic cleanup process") ctx = ent.NewContext(ctx, w.Ent) - ctx = schematype.SkipSoftDelete(ctx) + // F-D72: Do NOT use SkipSoftDelete. GC should respect soft-delete semantics: + // only hard-delete records that have been soft-deleted past their retention period. // Get storage policy policy, err := w.SystemService.StoragePolicy(ctx) @@ -147,48 +168,32 @@ func (w *Worker) runCleanup(ctx context.Context, manual bool) { if option.Enabled { switch option.ResourceType { case "requests": - err := w.cleanupRequests(ctx, option.CleanupDays, manual) + err := w.cleanupRequests(ctx, option.CleanupDays, manual, stats) if err != nil { log.Error(ctx, "Failed to cleanup requests", log.String("resource", option.ResourceType), log.Cause(err)) - } else { - log.Info(ctx, "Successfully cleaned up requests", - log.String("resource", option.ResourceType), - log.Int("cleanup_days", option.CleanupDays)) } - err = w.cleanupThreads(ctx, option.CleanupDays, manual) + err = w.cleanupThreads(ctx, option.CleanupDays, manual, stats) if err != nil { log.Error(ctx, "Failed to cleanup threads", log.String("resource", "threads"), log.Cause(err)) - } else { - log.Info(ctx, "Successfully cleaned up threads", - log.String("resource", "threads"), - log.Int("cleanup_days", option.CleanupDays)) } - err = w.cleanupTraces(ctx, option.CleanupDays, manual) + err = w.cleanupTraces(ctx, option.CleanupDays, manual, stats) if err != nil { log.Error(ctx, "Failed to cleanup traces", log.String("resource", "traces"), log.Cause(err)) - } else { - log.Info(ctx, "Successfully cleaned up traces", - log.String("resource", "traces"), - log.Int("cleanup_days", option.CleanupDays)) } case "usage_logs": - err := w.cleanupUsageLogs(ctx, option.CleanupDays, manual) + err := w.cleanupUsageLogs(ctx, option.CleanupDays, manual, stats) if err != nil { log.Error(ctx, "Failed to cleanup usage logs", log.String("resource", option.ResourceType), log.Cause(err)) - } else { - log.Info(ctx, "Successfully cleaned up usage logs", - log.String("resource", option.ResourceType), - log.Int("cleanup_days", option.CleanupDays)) } default: log.Warn(ctx, "Unknown resource type for cleanup", @@ -198,13 +203,10 @@ func (w *Worker) runCleanup(ctx context.Context, manual bool) { } // Always cleanup channel probe data older than 3 days - err = w.cleanupChannelProbes(ctx, 3, manual) + err = w.cleanupChannelProbes(ctx, 3, manual, stats) if err != nil { log.Error(ctx, "Failed to cleanup channel probes", log.Cause(err)) - } else { - log.Info(ctx, "Successfully cleaned up channel probes", - log.Int("cleanup_days", 3)) } // Run VACUUM after cleanup to reclaim storage space (SQLite and PostgreSQL) @@ -215,11 +217,88 @@ func (w *Worker) runCleanup(ctx context.Context, manual bool) { } } + // F-D58: Structured summary with deletion counts after cleanup + if stats != nil { + var parts []string + if stats.RequestExecsDeleted > 0 { + parts = append(parts, fmt.Sprintf("request_execs=%d", stats.RequestExecsDeleted)) + } + if stats.RequestsDeleted > 0 { + parts = append(parts, fmt.Sprintf("requests=%d", stats.RequestsDeleted)) + } + if stats.ThreadsDeleted > 0 { + parts = append(parts, fmt.Sprintf("threads=%d", stats.ThreadsDeleted)) + } + if stats.TracesDeleted > 0 { + parts = append(parts, fmt.Sprintf("traces=%d", stats.TracesDeleted)) + } + if stats.UsageLogsDeleted > 0 { + parts = append(parts, fmt.Sprintf("usage_logs=%d", stats.UsageLogsDeleted)) + } + if stats.ChannelProbesDeleted > 0 { + parts = append(parts, fmt.Sprintf("channel_probes=%d", stats.ChannelProbesDeleted)) + } + if len(parts) > 0 { + total := stats.RequestExecsDeleted + stats.RequestsDeleted + + stats.ThreadsDeleted + stats.TracesDeleted + + stats.UsageLogsDeleted + stats.ChannelProbesDeleted + log.Info(ctx, "GC cleanup completed", + log.Int("total_deleted", total), + log.String("breakdown", strings.Join(parts, ", "))) + } + } + log.Info(ctx, "Automatic cleanup process completed") } +// runCleanupWithSystemContext wraps runCleanup with system context. +// F-D34: Tracks in-flight cleanup via WaitGroup for graceful shutdown. +func (w *Worker) runCleanupWithSystemContext(ctx context.Context) { + w.wg.Add(1) + defer w.wg.Done() + + stats := CleanupStats{} + w.runCleanup(ctx, false, &stats) +} + +// F-D7: retryWithBackoff retries an operation up to maxAttempts times +// with exponential backoff. Returns nil on success, last error on failure. +func retryWithBackoff(ctx context.Context, maxAttempts int, op func() error) error { + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + err := op() + if err == nil { + return nil + } + lastErr = err + if attempt < maxAttempts-1 { + backoff := time.Duration(1<= %s THEN 1 ELSE 0 END) AS today, + SUM(CASE WHEN created_at >= %s THEN 1 ELSE 0 END) AS this_week, + SUM(CASE WHEN created_at >= %s AND created_at < %s THEN 1 ELSE 0 END) AS last_week, + SUM(CASE WHEN created_at >= %s THEN 1 ELSE 0 END) AS this_month + FROM usage_logs`, + p(1), p(2), p(3), p(4), p(5), + ) + + var today, thisWeek, lastWeek, thisMonth sql.NullInt64 + rows, err := sqlDB.DB().QueryContext(ctx, query, + period.Today.Start.UTC(), + period.ThisWeek.Start.UTC(), + period.LastWeek.Start.UTC(), + period.LastWeek.End.UTC(), + period.ThisMonth.Start.UTC(), + ) + if err != nil { + log.Warn(ctx, "failed to query request stats", log.Cause(err)) + return stats, nil } + defer func() { _ = rows.Close() }() - if requestsThisMonth, err := r.client.UsageLog.Query(). - Where(usagelog.CreatedAtGTE(period.ThisMonth.Start)). - Count(ctx); err != nil { - log.Warn(ctx, "failed to count this month's requests", log.Cause(err)) - } else { - stats.RequestsThisMonth = requestsThisMonth + if rows.Next() { + if err := rows.Scan(&today, &thisWeek, &lastWeek, &thisMonth); err != nil { + log.Warn(ctx, "failed to scan request stats", log.Cause(err)) + return stats, nil + } + } + + if today.Valid { + stats.RequestsToday = int(today.Int64) + } + if thisWeek.Valid { + stats.RequestsThisWeek = int(thisWeek.Int64) + } + if lastWeek.Valid { + stats.RequestsLastWeek = int(lastWeek.Int64) + } + if thisMonth.Valid { + stats.RequestsThisMonth = int(thisMonth.Int64) } return stats, nil @@ -693,65 +720,53 @@ func (r *queryResolver) TokenStats(ctx context.Context) (*TokenStats, error) { } loc := r.systemService.TimeLocation(ctx) - period := xtime.GetCalendarPeriods(loc) - - // Helper function to get token sums for a specific time period - getTokenSums := func(since time.Time) (input, output, cached int) { - type tokenSums struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CachedTokens int `json:"cached_tokens"` - } - - var records []tokenSums - - err := r.client.UsageLog.Query(). - Where(usagelog.CreatedAtGTE(since)). - Modify(func(s *sql.Selector) { - s.Select( - sql.As(sql.Sum(usagelog.FieldPromptTokens), "input_tokens"), - sql.As(sql.Sum(usagelog.FieldCompletionTokens), "output_tokens"), - sql.As(fmt.Sprintf("COALESCE(SUM(%s), 0)", s.C(usagelog.FieldPromptCachedTokens)), "cached_tokens"), - ) - }). - Scan(ctx, &records) - if err != nil || len(records) == 0 { - log.Warn(ctx, "failed to aggregate token stats", log.Cause(err)) - return 0, 0, 0 - } - - inputVal := records[0].InputTokens - outputVal := records[0].OutputTokens - cachedVal := records[0].CachedTokens - - if log.DebugEnabled(ctx) { - log.Debug(ctx, "token stats query result", - log.String("since", since.Format("2006-01-02 15:04:05")), - log.Int("input", inputVal), - log.Int("output", outputVal), - log.Int("cached", cachedVal)) - } + _ = xtime.GetCalendarPeriods(loc) - return inputVal, outputVal, cachedVal + // Single aggregation query with conditional sums for all time periods + type periodTokenSums struct { + TodayInput int `json:"today_input"` + TodayOutput int `json:"today_output"` + TodayCached int `json:"today_cached"` + WeekInput int `json:"week_input"` + WeekOutput int `json:"week_output"` + WeekCached int `json:"week_cached"` + MonthInput int `json:"month_input"` + MonthOutput int `json:"month_output"` + MonthCached int `json:"month_cached"` } - // Get token stats for today - input, output, cached := getTokenSums(period.Today.Start) - stats.TotalInputTokensToday = input - stats.TotalOutputTokensToday = output - stats.TotalCachedTokensToday = cached - - // Get token stats for this week (calendar week from Monday) - input, output, cached = getTokenSums(period.ThisWeek.Start) - stats.TotalInputTokensThisWeek = input - stats.TotalOutputTokensThisWeek = output - stats.TotalCachedTokensThisWeek = cached + var periodResults []periodTokenSums - // Get token stats for this month (calendar month from 1st) - input, output, cached = getTokenSums(period.ThisMonth.Start) - stats.TotalInputTokensThisMonth = input - stats.TotalOutputTokensThisMonth = output - stats.TotalCachedTokensThisMonth = cached + err := r.client.UsageLog.Query(). + Modify(func(s *sql.Selector) { + createdAt := s.C(usagelog.FieldCreatedAt) + s.Select( + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptTokens), "today_input"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldCompletionTokens), "today_output"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptCachedTokens), "today_cached"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptTokens), "week_input"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldCompletionTokens), "week_output"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptCachedTokens), "week_cached"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptTokens), "month_input"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldCompletionTokens), "month_output"), + sql.As(fmt.Sprintf("COALESCE(SUM(CASE WHEN %s >= ? THEN %s ELSE 0 END), 0)", createdAt, usagelog.FieldPromptCachedTokens), "month_cached"), + ) + }). + Scan(ctx, &periodResults) + if err != nil || len(periodResults) == 0 { + log.Warn(ctx, "failed to aggregate period token stats", log.Cause(err)) + } else { + r := periodResults[0] + stats.TotalInputTokensToday = r.TodayInput + stats.TotalOutputTokensToday = r.TodayOutput + stats.TotalCachedTokensToday = r.TodayCached + stats.TotalInputTokensThisWeek = r.WeekInput + stats.TotalOutputTokensThisWeek = r.WeekOutput + stats.TotalCachedTokensThisWeek = r.WeekCached + stats.TotalInputTokensThisMonth = r.MonthInput + stats.TotalOutputTokensThisMonth = r.MonthOutput + stats.TotalCachedTokensThisMonth = r.MonthCached + } // Get all-time token stats with stale-while-revalidate caching allTimeCacheMu.RLock() diff --git a/internal/server/gql/ent.graphql b/internal/server/gql/ent.graphql index 4f909343f..0f5f58fbc 100644 --- a/internal/server/gql/ent.graphql +++ b/internal/server/gql/ent.graphql @@ -13,6 +13,10 @@ type APIKey implements Node { """ projectID: ID! key: String! + """ + SHA-256 hash of the API key for secure verification + """ + keyHash: String name: String! """ API Key type: user, service_account, or noauth @@ -198,6 +202,24 @@ input APIKeyWhereInput { keyEqualFold: String keyContainsFold: String """ + key_hash field predicates + """ + keyHash: String + keyHashNEQ: String + keyHashIn: [String!] + keyHashNotIn: [String!] + keyHashGT: String + keyHashGTE: String + keyHashLT: String + keyHashLTE: String + keyHashContains: String + keyHashHasPrefix: String + keyHashHasSuffix: String + keyHashIsNil: Boolean + keyHashNotNil: Boolean + keyHashEqualFold: String + keyHashContainsFold: String + """ name field predicates """ name: String diff --git a/internal/server/gql/ent.resolvers.go b/internal/server/gql/ent.resolvers.go index f319bb070..b2648295e 100644 --- a/internal/server/gql/ent.resolvers.go +++ b/internal/server/gql/ent.resolvers.go @@ -256,9 +256,30 @@ func (r *queryResolver) Node(ctx context.Context, id objects.GUID) (ent.Noder, e // Nodes is the resolver for the nodes field. func (r *queryResolver) Nodes(ctx context.Context, ids []*objects.GUID) ([]ent.Noder, error) { - panic(fmt.Errorf("not implemented: Nodes - nodes")) + result := make([]ent.Noder, len(ids)) + for i, id := range ids { + if id == nil { + continue + } + typ, ok := guidTypeToNodeType[id.Type] + if !ok { + result[i] = nil + continue + } + noder, err := r.client.Noder(ctx, id.ID, ent.WithFixedNodeType(typ)) + if ent.IsNotFound(err) { + result[i] = nil + continue + } + if err != nil { + return nil, err + } + result[i] = noder + } + return result, nil } + // APIKeys is the resolver for the apiKeys field. func (r *queryResolver) APIKeys(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, orderBy *ent.APIKeyOrder, where *ent.APIKeyWhereInput) (*ent.APIKeyConnection, error) { if err := validatePaginationArgs(first, last); err != nil { @@ -277,6 +298,10 @@ func (r *queryResolver) APIKeys(ctx context.Context, after *entgql.Cursor[int], // Channels is the resolver for the channels field. func (r *queryResolver) Channels(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, orderBy *ent.ChannelOrder, where *ent.ChannelWhereInput) (*ent.ChannelConnection, error) { + if err := validatePaginationArgs(first, last); err != nil { + return nil, err + } + if orderBy != nil && orderBy.Field.String() == "CREATED_AT" { orderBy.Field = ent.DefaultChannelOrder.Field } @@ -321,6 +346,10 @@ func (r *queryResolver) DataStorages(ctx context.Context, after *entgql.Cursor[i // Models is the resolver for the models field. func (r *queryResolver) Models(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, orderBy *ent.ModelOrder, where *ent.ModelWhereInput) (*ent.ModelConnection, error) { + if err := validatePaginationArgs(first, last); err != nil { + return nil, err + } + if orderBy != nil && orderBy.Field.String() == "CREATED_AT" { orderBy.Field = ent.DefaultModelOrder.Field } @@ -340,7 +369,7 @@ func (r *queryResolver) Projects(ctx context.Context, after *entgql.Cursor[int], orderBy.Field = ent.DefaultProjectOrder.Field } - return r.client.Project.Query().Paginate(ctx, after, first, before, last, + return r.client.Project.Query().WithUsers().WithRoles().Paginate(ctx, after, first, before, last, ent.WithProjectOrder(orderBy), ent.WithProjectFilter(where.Filter), ) @@ -404,7 +433,7 @@ func (r *queryResolver) Roles(ctx context.Context, after *entgql.Cursor[int], fi orderBy.Field = ent.DefaultRoleOrder.Field } - return r.client.Role.Query().Paginate(ctx, after, first, before, last, + return r.client.Role.Query().WithUserRoles().Paginate(ctx, after, first, before, last, ent.WithRoleOrder(orderBy), ent.WithRoleFilter(where.Filter), ) @@ -480,7 +509,7 @@ func (r *queryResolver) Users(ctx context.Context, after *entgql.Cursor[int], fi orderBy.Field = ent.DefaultUserOrder.Field } - return r.client.User.Query().Paginate(ctx, after, first, before, last, + return r.client.User.Query().WithProjectUsers().WithUserRoles().Paginate(ctx, after, first, before, last, ent.WithUserOrder(orderBy), ent.WithUserFilter(where.Filter), ) diff --git a/internal/server/gql/generated.go b/internal/server/gql/generated.go index 43c193472..4af83c65c 100644 --- a/internal/server/gql/generated.go +++ b/internal/server/gql/generated.go @@ -99,6 +99,7 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int ID func(childComplexity int) int Key func(childComplexity int) int + KeyHash func(childComplexity int) int Name func(childComplexity int) int Profiles func(childComplexity int) int Project func(childComplexity int) int @@ -2169,6 +2170,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.APIKey.Key(childComplexity), true + case "APIKey.keyHash": + if e.complexity.APIKey.KeyHash == nil { + break + } + + return e.complexity.APIKey.KeyHash(childComplexity), true case "APIKey.name": if e.complexity.APIKey.Name == nil { break @@ -13053,6 +13060,35 @@ func (ec *executionContext) fieldContext_APIKey_key(_ context.Context, field gra return fc, nil } +func (ec *executionContext) _APIKey_keyHash(ctx context.Context, field graphql.CollectedField, obj *ent.APIKey) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_APIKey_keyHash, + func(ctx context.Context) (any, error) { + return obj.KeyHash, nil + }, + nil, + ec.marshalOString2string, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_APIKey_keyHash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "APIKey", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _APIKey_name(ctx context.Context, field graphql.CollectedField, obj *ent.APIKey) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -13520,6 +13556,8 @@ func (ec *executionContext) fieldContext_APIKeyEdge_node(_ context.Context, fiel return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -27387,6 +27425,8 @@ func (ec *executionContext) fieldContext_Mutation_createAPIKey(ctx context.Conte return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -27458,6 +27498,8 @@ func (ec *executionContext) fieldContext_Mutation_updateAPIKey(ctx context.Conte return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -27529,6 +27571,8 @@ func (ec *executionContext) fieldContext_Mutation_updateAPIKeyStatus(ctx context return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -27600,6 +27644,8 @@ func (ec *executionContext) fieldContext_Mutation_updateAPIKeyProfiles(ctx conte return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -39268,6 +39314,8 @@ func (ec *executionContext) fieldContext_Request_apiKey(_ context.Context, field return ec.fieldContext_APIKey_projectID(ctx, field) case "key": return ec.fieldContext_APIKey_key(ctx, field) + case "keyHash": + return ec.fieldContext_APIKey_keyHash(ctx, field) case "name": return ec.fieldContext_APIKey_name(ctx, field) case "type": @@ -53443,7 +53491,7 @@ func (ec *executionContext) unmarshalInputAPIKeyWhereInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"not", "and", "or", "id", "idNEQ", "idIn", "idNotIn", "idGT", "idGTE", "idLT", "idLTE", "createdAt", "createdAtNEQ", "createdAtIn", "createdAtNotIn", "createdAtGT", "createdAtGTE", "createdAtLT", "createdAtLTE", "updatedAt", "updatedAtNEQ", "updatedAtIn", "updatedAtNotIn", "updatedAtGT", "updatedAtGTE", "updatedAtLT", "updatedAtLTE", "userID", "userIDNEQ", "userIDIn", "userIDNotIn", "userIDIsNil", "userIDNotNil", "projectID", "projectIDNEQ", "projectIDIn", "projectIDNotIn", "key", "keyNEQ", "keyIn", "keyNotIn", "keyGT", "keyGTE", "keyLT", "keyLTE", "keyContains", "keyHasPrefix", "keyHasSuffix", "keyEqualFold", "keyContainsFold", "name", "nameNEQ", "nameIn", "nameNotIn", "nameGT", "nameGTE", "nameLT", "nameLTE", "nameContains", "nameHasPrefix", "nameHasSuffix", "nameEqualFold", "nameContainsFold", "type", "typeNEQ", "typeIn", "typeNotIn", "status", "statusNEQ", "statusIn", "statusNotIn", "hasUser", "hasUserWith", "hasProject", "hasProjectWith", "hasRequests", "hasRequestsWith"} + fieldsInOrder := [...]string{"not", "and", "or", "id", "idNEQ", "idIn", "idNotIn", "idGT", "idGTE", "idLT", "idLTE", "createdAt", "createdAtNEQ", "createdAtIn", "createdAtNotIn", "createdAtGT", "createdAtGTE", "createdAtLT", "createdAtLTE", "updatedAt", "updatedAtNEQ", "updatedAtIn", "updatedAtNotIn", "updatedAtGT", "updatedAtGTE", "updatedAtLT", "updatedAtLTE", "userID", "userIDNEQ", "userIDIn", "userIDNotIn", "userIDIsNil", "userIDNotNil", "projectID", "projectIDNEQ", "projectIDIn", "projectIDNotIn", "key", "keyNEQ", "keyIn", "keyNotIn", "keyGT", "keyGTE", "keyLT", "keyLTE", "keyContains", "keyHasPrefix", "keyHasSuffix", "keyEqualFold", "keyContainsFold", "keyHash", "keyHashNEQ", "keyHashIn", "keyHashNotIn", "keyHashGT", "keyHashGTE", "keyHashLT", "keyHashLTE", "keyHashContains", "keyHashHasPrefix", "keyHashHasSuffix", "keyHashIsNil", "keyHashNotNil", "keyHashEqualFold", "keyHashContainsFold", "name", "nameNEQ", "nameIn", "nameNotIn", "nameGT", "nameGTE", "nameLT", "nameLTE", "nameContains", "nameHasPrefix", "nameHasSuffix", "nameEqualFold", "nameContainsFold", "type", "typeNEQ", "typeIn", "typeNotIn", "status", "statusNEQ", "statusIn", "statusNotIn", "hasUser", "hasUserWith", "hasProject", "hasProjectWith", "hasRequests", "hasRequestsWith"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -53864,6 +53912,111 @@ func (ec *executionContext) unmarshalInputAPIKeyWhereInput(ctx context.Context, return it, err } it.KeyContainsFold = data + case "keyHash": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHash")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHash = data + case "keyHashNEQ": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashNEQ")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashNEQ = data + case "keyHashIn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashIn")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.KeyHashIn = data + case "keyHashNotIn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashNotIn")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.KeyHashNotIn = data + case "keyHashGT": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashGT")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashGT = data + case "keyHashGTE": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashGTE")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashGTE = data + case "keyHashLT": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashLT")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashLT = data + case "keyHashLTE": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashLTE")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashLTE = data + case "keyHashContains": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashContains")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashContains = data + case "keyHashHasPrefix": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashHasPrefix")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashHasPrefix = data + case "keyHashHasSuffix": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashHasSuffix")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashHasSuffix = data + case "keyHashIsNil": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashIsNil")) + data, err := ec.unmarshalOBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.KeyHashIsNil = data + case "keyHashNotNil": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashNotNil")) + data, err := ec.unmarshalOBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.KeyHashNotNil = data + case "keyHashEqualFold": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashEqualFold")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashEqualFold = data + case "keyHashContainsFold": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("keyHashContainsFold")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.KeyHashContainsFold = data case "name": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -68976,7 +69129,7 @@ func (ec *executionContext) unmarshalInputS3Input(ctx context.Context, obj any) it.AccessKey = data case "secretKey": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("secretKey")) - data, err := ec.unmarshalOString2string(ctx, v) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) if err != nil { return it, err } @@ -76514,6 +76667,8 @@ func (ec *executionContext) _APIKey(ctx context.Context, sel ast.SelectionSet, o if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "keyHash": + out.Values[i] = ec._APIKey_keyHash(ctx, field, obj) case "name": out.Values[i] = ec._APIKey_name(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/internal/server/gql/graphql.go b/internal/server/gql/graphql.go index a71e37780..a3b9cad47 100644 --- a/internal/server/gql/graphql.go +++ b/internal/server/gql/graphql.go @@ -119,6 +119,8 @@ func NewGraphqlHandlers(deps Dependencies) *GraphqlHandler { gqlSrv.Use(extension.AutomaticPersistedQuery{ Cache: lru.New[string](1024), }) + // Limit query complexity to prevent DoS via deeply nested or expensive queries. + gqlSrv.Use(extension.FixedComplexityLimit(10_000)) gqlSrv.Use(&loggingTracer{}) gqlSrv.Use(entgql.Transactioner{ TxOpener: deps.Ent, diff --git a/internal/server/gql/me.resolvers.go b/internal/server/gql/me.resolvers.go index a6c85af0b..c9857e3fa 100644 --- a/internal/server/gql/me.resolvers.go +++ b/internal/server/gql/me.resolvers.go @@ -54,7 +54,10 @@ func (r *queryResolver) MyProjects(ctx context.Context) ([]*ent.Project, error) return nil, fmt.Errorf("user not found in context") } - ctx = authz.WithSystemBypass(ctx, "read-my-projects") + ctx, err := authz.WithSystemBypass(ctx, "read-my-projects") + if err != nil { + return nil, err + } return r.client.Project.Query(). Where(project.HasUsersWith(user.IDEQ(u.ID))). diff --git a/internal/server/gql/system.resolvers.go b/internal/server/gql/system.resolvers.go index babd20d9f..d308076c4 100644 --- a/internal/server/gql/system.resolvers.go +++ b/internal/server/gql/system.resolvers.go @@ -8,6 +8,7 @@ package gql import ( "context" "fmt" + "log/slog" "time" "github.com/looplj/axonhub/internal/authz" @@ -177,13 +178,17 @@ func (r *mutationResolver) TriggerGcCleanup(ctx context.Context) (bool, error) { defer func() { if rec := recover(); rec != nil { // Log the panic or handle it - assuming there's a logging mechanism or just preventing crash - fmt.Printf("Recovered from panic in GC goroutine: %v\n", rec) + slog.Warn("Recovered from panic in GC goroutine", "panic", rec) } }() // Use a detached context with system bypass for background execution - bgCtx := authz.WithSystemBypass(context.WithoutCancel(ctx), "manual-gc-cleanup") - _ = r.gcWorker.RunCleanupNow(bgCtx) + bgCtx, err := authz.WithSystemBypass(context.WithoutCancel(ctx), "manual-gc-cleanup") + if err != nil { + slog.Error("failed to create GC bypass context", "error", err) + return + } + _, _ = r.gcWorker.RunCleanupNow(bgCtx) }() return true, nil diff --git a/internal/server/gql/system.resolvers_test.go b/internal/server/gql/system.resolvers_test.go index 463d67270..c0c81cbd2 100644 --- a/internal/server/gql/system.resolvers_test.go +++ b/internal/server/gql/system.resolvers_test.go @@ -18,7 +18,7 @@ func setupTestSystemMutationResolver(t *testing.T) (*mutationResolver, context.C client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") systemService := &biz.SystemService{ - Cache: xcache.NewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.System](xcache.Config{Mode: xcache.ModeMemory}), } ctx := context.Background() diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index 566447460..42944fe7b 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -26,7 +26,9 @@ func WithAPIKeyConfig(auth *biz.AuthService, config *APIKeyConfig) gin.HandlerFu return func(c *gin.Context) { key, err := ExtractAPIKeyFromRequest(c.Request, config) // DO NOT ALLOW USE NO AUTH API KEY DIRECTLY. - if key == biz.NoAuthAPIKeyValue { + // Only reject empty keys when one was actually extracted (err == nil). + // When err == ErrAPIKeyRequired, let the flow continue to AuthenticateNoAuth. + if err == nil && key == biz.NoAuthAPIKeyValue { AbortWithError(c, http.StatusUnauthorized, errors.New("Invalid API key")) return } @@ -35,7 +37,7 @@ func WithAPIKeyConfig(auth *biz.AuthService, config *APIKeyConfig) gin.HandlerFu if err == nil { apiKey, err = auth.AuthenticateAPIKey(c.Request.Context(), key) } - if err != nil { + if errors.Is(err, ErrAPIKeyRequired) { apiKey, err = auth.AuthenticateNoAuth(c.Request.Context()) } if err != nil { @@ -153,15 +155,10 @@ func WithOpenAPIAuth(auth *biz.AuthService) gin.HandlerFunc { // https://ai.google.dev/api/generate-content?hl=zh-cn#text_gen_text_only_prompt-SHELL func WithGeminiKeyAuth(auth *biz.AuthService) gin.HandlerFunc { return func(c *gin.Context) { - key := c.Query("key") - if key == "" { - var err error - - key, err = ExtractAPIKeyFromRequest(c.Request, nil) - if err != nil { - AbortWithError(c, http.StatusUnauthorized, err) - return - } + key, err := ExtractAPIKeyFromRequest(c.Request, nil) + if err != nil { + AbortWithError(c, http.StatusUnauthorized, err) + return } apiKey, err := auth.AuthenticateAPIKey(c.Request.Context(), key) diff --git a/internal/server/middleware/project.go b/internal/server/middleware/project.go index 8d4e3fc6c..e6b8b3401 100644 --- a/internal/server/middleware/project.go +++ b/internal/server/middleware/project.go @@ -8,6 +8,8 @@ import ( "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/userproject" + "github.com/looplj/axonhub/internal/log" "github.com/looplj/axonhub/internal/objects" ) @@ -25,6 +27,26 @@ func WithProjectID() gin.HandlerFunc { return } + // Verify the authenticated user has access to the requested project. + // Skip check for API key auth (project is bound to the key itself). + if user, ok := contexts.GetUser(c.Request.Context()); ok && user != nil { + client := ent.FromContext(c.Request.Context()) + if client != nil { + exists, err := client.UserProject.Query(). + Where(userproject.UserID(user.ID), userproject.ProjectID(projectID.ID)). + Exist(c.Request.Context()) + if err != nil { + log.Error(c.Request.Context(), "failed to verify project access", log.Cause(err)) + AbortWithError(c, http.StatusInternalServerError, errors.New("Failed to verify project access")) + return + } + if !exists { + AbortWithError(c, http.StatusForbidden, errors.New("Access denied: you do not have access to this project")) + return + } + } + } + ctx := contexts.WithProjectID(c.Request.Context(), projectID.ID) c.Request = c.Request.WithContext(ctx) diff --git a/internal/server/middleware/recover.go b/internal/server/middleware/recover.go index 1529bbb86..a262149d9 100644 --- a/internal/server/middleware/recover.go +++ b/internal/server/middleware/recover.go @@ -2,10 +2,14 @@ package middleware import ( "bytes" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/http" "runtime" + "strings" + "time" "github.com/gin-gonic/gin" @@ -24,17 +28,24 @@ func RecoveryWithWriter() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { - // Get stack trace + // Generate short panic ID for correlation + panicID := generatePanicID() stack := stack(3) - // Log detailed error information + // Log error message with short ID at error level (no stack) log.Error( c.Request.Context(), - fmt.Sprintf("[PANIC] %s", err), + fmt.Sprintf("[PANIC-%s] %s", panicID, err), log.String("path", c.Request.URL.Path), log.String("method", c.Request.Method), log.String("client_ip", c.ClientIP()), log.String("user_agent", c.Request.UserAgent()), + ) + + // Log full sanitized stack trace at debug level only + log.Debug( + c.Request.Context(), + fmt.Sprintf("[PANIC-%s] Stack trace", panicID), log.String("stack", string(stack)), ) @@ -65,10 +76,14 @@ func stack(skip int) []byte { continue } + // Sanitize file path: extract only the last two path components + // to avoid leaking internal server file paths in production logs. + sanitizedFile := sanitizeFilePath(file) + // Only print the file name when it changes - if file != lastFile { - fmt.Fprintf(buf, "\n%s:", file) - lastFile = file + if sanitizedFile != lastFile { + fmt.Fprintf(buf, "\n%s:", sanitizedFile) + lastFile = sanitizedFile } // Print function name and line @@ -80,3 +95,25 @@ func stack(skip int) []byte { return buf.Bytes() } + +// sanitizeFilePath strips directory components from a file path, +// keeping only the last two segments (e.g., "pkg/middleware/recover.go") +// to avoid leaking internal server paths in log output. +func sanitizeFilePath(path string) string { + lastSep := strings.LastIndex(path, "/") + if lastSep == -1 { + return path + } + secondLastSep := strings.LastIndex(path[:lastSep], "/") + if secondLastSep == -1 { + return path + } + return path[secondLastSep+1:] +} + +// generatePanicID creates a short, unique identifier for a panic event +// to correlate error logs with debug-level stack traces. +func generatePanicID() string { + h := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + return hex.EncodeToString(h[:])[:8] +} diff --git a/internal/server/middleware/thread.go b/internal/server/middleware/thread.go index 308ad2100..c53ba49b0 100644 --- a/internal/server/middleware/thread.go +++ b/internal/server/middleware/thread.go @@ -34,7 +34,12 @@ func WithThread(config tracing.Config, threadService *biz.ThreadService) gin.Han } // Bypass privacy policy so tokens without write_requests scope can still trigger thread tracking. - bypassCtx := authz.WithSystemBypass(c.Request.Context(), "thread-middleware") + bypassCtx, err := authz.WithSystemBypass(c.Request.Context(), "thread-middleware") + if err != nil { + log.Error(c.Request.Context(), "failed to create bypass context for thread middleware", log.Cause(err)) + c.Next() + return + } // Get or create thread (errors are logged but don't block the request) thread, err := threadService.GetOrCreateThread(bypassCtx, projectID, threadID) diff --git a/internal/server/middleware/thread_test.go b/internal/server/middleware/thread_test.go index 0f669d70e..85676aecf 100644 --- a/internal/server/middleware/thread_test.go +++ b/internal/server/middleware/thread_test.go @@ -26,16 +26,18 @@ func setupTestThreadMiddleware(t *testing.T) (*gin.Engine, *ent.Client, *biz.Thr client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) - dataStorageService := biz.NewDataStorageService(biz.DataStorageServiceParams{ + require.NoError(t, err) + dataStorageService, err := biz.NewDataStorageService(biz.DataStorageServiceParams{ Client: client, SystemService: systemService, CacheConfig: xcache.Config{}, Executor: executors.NewPoolScheduleExecutor(), }) + require.NoError(t, err) channelService := biz.NewChannelServiceForTest(client) usageLogService := biz.NewUsageLogService(client, systemService, channelService) traceService := biz.NewTraceService(biz.TraceServiceParams{ diff --git a/internal/server/middleware/trace.go b/internal/server/middleware/trace.go index 2ad6db1e8..8476f11c1 100644 --- a/internal/server/middleware/trace.go +++ b/internal/server/middleware/trace.go @@ -121,7 +121,12 @@ func WithTrace(config tracing.Config, traceService *biz.TraceService) gin.Handle } // Bypass privacy policy so tokens without write_requests scope can still trigger tracing. - bypassCtx := authz.WithSystemBypass(c.Request.Context(), "trace-middleware") + bypassCtx, err := authz.WithSystemBypass(c.Request.Context(), "trace-middleware") + if err != nil { + log.Error(c.Request.Context(), "failed to create bypass context for tracing", log.Cause(err)) + c.Next() + return + } // Get or create trace (errors are logged but don't block the request) trace, err := traceService.GetOrCreateTrace(bypassCtx, projectID, traceID, threadID) @@ -134,7 +139,9 @@ func WithTrace(config tracing.Config, traceService *biz.TraceService) gin.Handle // Store trace in context if log.DebugEnabled(c.Request.Context()) { - log.Debug(c.Request.Context(), "Trace created", log.Any("trace", trace)) + log.Debug(c.Request.Context(), "Trace created", + log.Int("trace_id", trace.ID), + log.String("trace_uuid", trace.TraceID)) } ctx := contexts.WithTrace(c.Request.Context(), trace) diff --git a/internal/server/middleware/trace_test.go b/internal/server/middleware/trace_test.go index df30de6c2..7e1dc359c 100644 --- a/internal/server/middleware/trace_test.go +++ b/internal/server/middleware/trace_test.go @@ -31,16 +31,18 @@ func setupTestTraceMiddleware(t *testing.T) (*gin.Engine, *ent.Client, *biz.Trac client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=1") - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) - dataStorageService := biz.NewDataStorageService(biz.DataStorageServiceParams{ + require.NoError(t, err) + dataStorageService, err := biz.NewDataStorageService(biz.DataStorageServiceParams{ Client: client, SystemService: systemService, CacheConfig: xcache.Config{}, Executor: executors.NewPoolScheduleExecutor(), }) + require.NoError(t, err) channelService := biz.NewChannelServiceForTest(client) usageLogService := biz.NewUsageLogService(client, systemService, channelService) traceService := biz.NewTraceService(biz.TraceServiceParams{ diff --git a/internal/server/orchestrator/candidates_basic_test.go b/internal/server/orchestrator/candidates_basic_test.go index 8643ec76a..721088793 100644 --- a/internal/server/orchestrator/candidates_basic_test.go +++ b/internal/server/orchestrator/candidates_basic_test.go @@ -25,8 +25,9 @@ func TestDefaultChannelSelector_Select_SingleChannel(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -48,9 +49,10 @@ func TestDefaultSelector_Select(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) selector := NewDefaultSelector(channelService, modelService, systemService) req := &llm.Request{ @@ -79,8 +81,9 @@ func TestDefaultSelector_Select(t *testing.T) { func TestDefaultChannelSelector_Select_NoChannelsAvailable(t *testing.T) { ctx, client := setupTest(t) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -111,8 +114,9 @@ func TestDefaultChannelSelector_Select_ModelNotSupported(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -133,8 +137,9 @@ func TestDefaultChannelSelector_Select_EmptyRequest(t *testing.T) { createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -164,7 +169,7 @@ func TestSpecifiedChannelSelector_Select_ValidChannel(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) selector := NewSpecifiedChannelSelector(channelService, objects.GUID{ID: ch.ID}) req := &llm.Request{ @@ -192,7 +197,7 @@ func TestSpecifiedChannelSelector_Select_ModelNotSupported(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) selector := NewSpecifiedChannelSelector(channelService, objects.GUID{ID: ch.ID}) req := &llm.Request{ @@ -209,7 +214,7 @@ func TestSpecifiedChannelSelector_Select_ModelNotSupported(t *testing.T) { func TestSpecifiedChannelSelector_Select_ChannelNotFound(t *testing.T) { ctx, client := setupTest(t) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) selector := NewSpecifiedChannelSelector(channelService, objects.GUID{ID: 999}) // Non-existent ID req := &llm.Request{ @@ -228,9 +233,10 @@ func TestSelectedChannelsSelector_Select_WithFilter(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) // Only allow channels 0 and 2 @@ -263,9 +269,10 @@ func TestSelectedChannelsSelector_Select_EmptyFilter(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) // Empty filter should return all channels diff --git a/internal/server/orchestrator/candidates_cache_test.go b/internal/server/orchestrator/candidates_cache_test.go index 4a3cb6800..861a7e7e8 100644 --- a/internal/server/orchestrator/candidates_cache_test.go +++ b/internal/server/orchestrator/candidates_cache_test.go @@ -49,9 +49,10 @@ func TestDefaultSelector_SelectModelCandidates_Cache(t *testing.T) { SaveX(ctx) // Create real services - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) // Create selector selector := NewDefaultSelector(channelService, modelService, systemService) @@ -326,7 +327,7 @@ func TestDefaultSelector_SelectModelCandidates_Cache(t *testing.T) { }). SaveX(ctx) - selector.ChannelService = newTestChannelServiceForChannels(client) + selector.ChannelService = newTestChannelServiceForChannels(t, client) smallReq := &llm.Request{ Model: conditionalModelID, @@ -411,7 +412,7 @@ func TestDefaultSelector_SelectModelCandidates_Cache(t *testing.T) { }). SaveX(ctx) - selector.ChannelService = newTestChannelServiceForChannels(client) + selector.ChannelService = newTestChannelServiceForChannels(t, client) streamTrue := true streamFalse := false @@ -496,7 +497,7 @@ func TestDefaultSelector_SelectModelCandidates_Cache(t *testing.T) { require.NoError(t, err) // Create a new channel service to see the new channels - newChannelService := newTestChannelServiceForChannels(client) + newChannelService := newTestChannelServiceForChannels(t, client) selector.ChannelService = newChannelService // First call to populate cache diff --git a/internal/server/orchestrator/candidates_decorator_test.go b/internal/server/orchestrator/candidates_decorator_test.go index 8dcbcb414..863f73c9a 100644 --- a/internal/server/orchestrator/candidates_decorator_test.go +++ b/internal/server/orchestrator/candidates_decorator_test.go @@ -14,8 +14,9 @@ func TestDecoratorChain_FullStack(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) strategies := []LoadBalanceStrategy{ @@ -58,8 +59,9 @@ func TestSelectedChannelsSelector_WithAllowedChannels(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -96,9 +98,10 @@ func TestSelectedChannelsSelector_WithEmptyFilter(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) req := &llm.Request{ diff --git a/internal/server/orchestrator/candidates_dedup_test.go b/internal/server/orchestrator/candidates_dedup_test.go index 63c68e928..909b3d208 100644 --- a/internal/server/orchestrator/candidates_dedup_test.go +++ b/internal/server/orchestrator/candidates_dedup_test.go @@ -31,9 +31,10 @@ func TestDefaultSelector_Select_Deduplication(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) selector := NewDefaultSelector(channelService, modelService, systemService) // Create a model with a regex association that matches multiple RequestModels @@ -91,9 +92,10 @@ func TestDefaultSelector_Select_AggregateSameChannelSamePriority(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) selector := NewDefaultSelector(channelService, modelService, systemService) model, err := client.Model.Create(). @@ -157,9 +159,10 @@ func TestDefaultSelector_Select_DeduplicateAcrossConditionalAssociationsByActual Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) selector := NewDefaultSelector(channelService, modelService, systemService) model, err := client.Model.Create(). diff --git a/internal/server/orchestrator/candidates_google_test.go b/internal/server/orchestrator/candidates_google_test.go index 0667b159d..9553be5eb 100644 --- a/internal/server/orchestrator/candidates_google_test.go +++ b/internal/server/orchestrator/candidates_google_test.go @@ -69,9 +69,10 @@ func TestGoogleNativeToolsSelector_Select_WithGoogleNativeTools(t *testing.T) { channels := createGeminiTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) selector := WithGoogleNativeToolsSelector(baseSelector) @@ -106,9 +107,10 @@ func TestGoogleNativeToolsSelector_Select_WithoutGoogleNativeTools(t *testing.T) channels := createGeminiTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) selector := WithGoogleNativeToolsSelector(baseSelector) @@ -153,9 +155,10 @@ func TestGoogleNativeToolsSelector_Select_NoCompatibleChannels(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) selector := WithGoogleNativeToolsSelector(baseSelector) @@ -182,9 +185,10 @@ func TestGoogleNativeToolsSelector_Select_EmptyTools(t *testing.T) { channels := createGeminiTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) selector := WithGoogleNativeToolsSelector(baseSelector) @@ -216,9 +220,10 @@ func TestGoogleNativeToolsSelector_Select_MultipleGoogleNativeTools(t *testing.T channels := createGeminiTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) + channelService := newTestChannelServiceForChannels(t, client) modelService := newTestModelService(client) - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) baseSelector := NewDefaultSelector(channelService, modelService, systemService) selector := WithGoogleNativeToolsSelector(baseSelector) diff --git a/internal/server/orchestrator/candidates_loadbalance_test.go b/internal/server/orchestrator/candidates_loadbalance_test.go index ca93d8927..61d1cd61c 100644 --- a/internal/server/orchestrator/candidates_loadbalance_test.go +++ b/internal/server/orchestrator/candidates_loadbalance_test.go @@ -20,8 +20,9 @@ func TestLoadBalancedSelector_Select_MultipleChannels_LoadBalancing(t *testing.T channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -68,8 +69,9 @@ func TestDefaultChannelSelector_Select_WithConnectionTracking(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -152,8 +154,9 @@ func TestDefaultChannelSelector_Select_WithTraceContext(t *testing.T) { // Add trace to context ctx = contexts.WithTrace(ctx, trace) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -197,8 +200,9 @@ func TestDefaultChannelSelector_Select_WithChannelFailures(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -296,8 +300,9 @@ func TestDefaultChannelSelector_Select_WeightedRoundRobin_EqualWeights(t *testin channels := []*ent.Channel{ch1, ch2, ch3} - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -387,8 +392,9 @@ func TestDefaultChannelSelector_Select_WeightedRoundRobin(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -458,8 +464,9 @@ func TestDefaultChannelSelector_Select_WithDisabledChannels(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) connectionTracker := NewDefaultConnectionTracker(10) @@ -491,8 +498,9 @@ func TestLoadBalancedSelector_Select(t *testing.T) { channels := createTestChannels(t, ctx, client) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) requestService := newTestRequestServiceForChannels(client, systemService) strategies := []LoadBalanceStrategy{ @@ -545,8 +553,9 @@ func TestLoadBalancedSelector_Select_SingleChannel(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelServiceForChannels(client) - systemService := newTestSystemService(client) + channelService := newTestChannelServiceForChannels(t, client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) loadBalancer := NewLoadBalancer(systemService, nil) modelService := newTestModelService(client) diff --git a/internal/server/orchestrator/channel_help_test.go b/internal/server/orchestrator/channel_help_test.go index a5c174162..a41ea3048 100644 --- a/internal/server/orchestrator/channel_help_test.go +++ b/internal/server/orchestrator/channel_help_test.go @@ -25,8 +25,11 @@ import ( ) // newTestChannelServiceForChannels creates a minimal channel service for testing. -func newTestChannelServiceForChannels(client *ent.Client) *biz.ChannelService { - systemService := newTestSystemService(client) +func newTestChannelServiceForChannels(t *testing.T, client *ent.Client) *biz.ChannelService { + t.Helper() + + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) return biz.NewChannelService(biz.ChannelServiceParams{ Executor: executors.NewPoolScheduleExecutor(), @@ -65,7 +68,9 @@ func newTestLoadBalancedSelector( } // newTestSystemService creates a minimal system service for testing. -func newTestSystemService(client *ent.Client) *biz.SystemService { +func newTestSystemService(t *testing.T, client *ent.Client) (*biz.SystemService, error) { + t.Helper() + return biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, Ent: client, @@ -77,7 +82,7 @@ func newTestRequestServiceForChannels(client *ent.Client, systemService *biz.Sys dataStorageService := &biz.DataStorageService{ AbstractService: &biz.AbstractService{}, SystemService: systemService, - Cache: xcache.NewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), } channelService := biz.NewChannelServiceForTest(client) usageLogService := biz.NewUsageLogService(client, systemService, channelService) @@ -217,15 +222,16 @@ func setupTestServices(t *testing.T, client *ent.Client) (*biz.ChannelService, * cacheConfig := xcache.Config{Mode: xcache.ModeMemory} - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: cacheConfig, Ent: client, }) + require.NoError(t, err) dataStorageService := &biz.DataStorageService{ AbstractService: &biz.AbstractService{}, SystemService: systemService, - Cache: xcache.NewFromConfig[ent.DataStorage](cacheConfig), + Cache: xcache.MustNewFromConfig[ent.DataStorage](cacheConfig), } channelService := biz.NewChannelServiceForTest(client) diff --git a/internal/server/orchestrator/channel_request_tracker.go b/internal/server/orchestrator/channel_request_tracker.go index 71b6765d2..006ce11c1 100644 --- a/internal/server/orchestrator/channel_request_tracker.go +++ b/internal/server/orchestrator/channel_request_tracker.go @@ -6,7 +6,7 @@ import ( ) // ChannelRequestTracker tracks per-channel request and token counts -// within a fixed 1-minute sliding window for rate limiting. +// within a 1-minute sliding window for rate limiting. // It also manages cooldown periods for channels that received 429 errors. type ChannelRequestTracker struct { mu sync.RWMutex @@ -14,10 +14,16 @@ type ChannelRequestTracker struct { cooldowns map[int]time.Time // channelID -> cooldown expiration time } +// rateLimitWindow holds counters for the current and previous minute window. type rateLimitWindow struct { requests int64 tokens int64 windowStart time.Time + + // Previous window counters, used for sliding window calculation. + prevRequests int64 + prevTokens int64 + prevWindowStart time.Time } // NewChannelRequestTracker creates a new rate limit tracker. @@ -29,14 +35,23 @@ func NewChannelRequestTracker() *ChannelRequestTracker { } // getOrResetWindow returns the current window for a channel, resetting if expired. +// When the minute boundary crosses, current counters are rotated into prev. func (t *ChannelRequestTracker) getOrResetWindow(channelID int) *rateLimitWindow { now := time.Now() windowStart := now.Truncate(time.Minute) w, ok := t.counters[channelID] - if !ok || w.windowStart != windowStart { + if !ok { w = &rateLimitWindow{windowStart: windowStart} t.counters[channelID] = w + } else if w.windowStart != windowStart { + // Window flipped: rotate current → previous + w.prevRequests = w.requests + w.prevTokens = w.tokens + w.prevWindowStart = w.windowStart + w.requests = 0 + w.tokens = 0 + w.windowStart = windowStart } return w @@ -64,7 +79,8 @@ func (t *ChannelRequestTracker) AddTokens(channelID int, tokens int64) { w.tokens += tokens } -// GetRequestCount returns the current request count for a channel in the current window. +// GetRequestCount returns the effective request count for a channel +// using a sliding window: effective = prev * (1 - elapsed/60s) + curr. func (t *ChannelRequestTracker) GetRequestCount(channelID int) int64 { t.mu.RLock() defer t.mu.RUnlock() @@ -76,13 +92,25 @@ func (t *ChannelRequestTracker) GetRequestCount(channelID int) int64 { windowStart := time.Now().Truncate(time.Minute) if w.windowStart != windowStart { + // Expired and no rotate happened; treat as empty. return 0 } + // Sliding window: weighted decay of previous window. + if w.prevWindowStart == windowStart.Add(-time.Minute) { + elapsed := time.Since(windowStart).Seconds() + weight := 1.0 - elapsed/60.0 + if weight < 0 { + weight = 0 + } + return int64(float64(w.prevRequests)*weight) + w.requests + } + return w.requests } -// GetTokenCount returns the current token count for a channel in the current window. +// GetTokenCount returns the effective token count for a channel +// using a sliding window: effective = prev * (1 - elapsed/60s) + curr. func (t *ChannelRequestTracker) GetTokenCount(channelID int) int64 { t.mu.RLock() defer t.mu.RUnlock() @@ -97,6 +125,16 @@ func (t *ChannelRequestTracker) GetTokenCount(channelID int) int64 { return 0 } + // Sliding window: weighted decay of previous window. + if w.prevWindowStart == windowStart.Add(-time.Minute) { + elapsed := time.Since(windowStart).Seconds() + weight := 1.0 - elapsed/60.0 + if weight < 0 { + weight = 0 + } + return int64(float64(w.prevTokens)*weight) + w.tokens + } + return w.tokens } diff --git a/internal/server/orchestrator/connection_tracking.go b/internal/server/orchestrator/connection_tracking.go index 5fd557295..aa0360907 100644 --- a/internal/server/orchestrator/connection_tracking.go +++ b/internal/server/orchestrator/connection_tracking.go @@ -3,6 +3,8 @@ package orchestrator import ( "context" + "sync" + "github.com/looplj/axonhub/internal/log" "github.com/looplj/axonhub/llm" "github.com/looplj/axonhub/llm/httpclient" @@ -97,7 +99,7 @@ type connectionTrackingStream struct { stream streams.Stream[*llm.Response] tracker ConnectionTracker outbound *PersistentOutboundTransformer - closed bool + closeOnce sync.Once } func (s *connectionTrackingStream) Current() *llm.Response { @@ -105,14 +107,15 @@ func (s *connectionTrackingStream) Current() *llm.Response { } func (s *connectionTrackingStream) Next() bool { - return s.stream.Next() + ok := s.stream.Next() + if !ok { + s.closeOnce.Do(s.decrementConnection) + } + return ok } func (s *connectionTrackingStream) Close() error { - if !s.closed { - s.closed = true - s.decrementConnection() - } + s.closeOnce.Do(s.decrementConnection) return s.stream.Close() } diff --git a/internal/server/orchestrator/inbound.go b/internal/server/orchestrator/inbound.go index b4c4014d0..3f8d414bc 100644 --- a/internal/server/orchestrator/inbound.go +++ b/internal/server/orchestrator/inbound.go @@ -13,6 +13,7 @@ import ( "github.com/looplj/axonhub/llm/httpclient" "github.com/looplj/axonhub/llm/streams" "github.com/looplj/axonhub/llm/transformer" + "sync/atomic" ) // InboundPersistentStream wraps a stream and tracks all responses for final saving to database. @@ -28,7 +29,7 @@ type InboundPersistentStream struct { transformer transformer.Inbound perf *biz.PerformanceRecord responseChunks []*httpclient.StreamEvent - closed bool + closed atomic.Bool state *PersistenceState } @@ -53,7 +54,6 @@ func NewInboundPersistentStream( transformer: transformer, perf: perf, responseChunks: make([]*httpclient.StreamEvent, 0), - closed: false, state: state, } @@ -92,11 +92,11 @@ func (ts *InboundPersistentStream) Err() error { } func (ts *InboundPersistentStream) Close() error { - if ts.closed { + if ts.closed.Load() { return nil } - ts.closed = true + ts.closed.Store(true) ctx := ts.ctx log.Debug(ctx, "Closing persistent stream", log.Int("chunk_count", len(ts.responseChunks)), log.Bool("received_done", ts.state.StreamCompleted)) diff --git a/internal/server/orchestrator/inbound_test.go b/internal/server/orchestrator/inbound_test.go index 5ddcd70be..38bcfb4a2 100644 --- a/internal/server/orchestrator/inbound_test.go +++ b/internal/server/orchestrator/inbound_test.go @@ -85,15 +85,16 @@ func (m *mockStream) Close() error { func createTestRequestService(t *testing.T, client *ent.Client) *biz.RequestService { t.Helper() - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, Ent: client, }) + require.NoError(t, err) dataStorageService := &biz.DataStorageService{ AbstractService: &biz.AbstractService{}, SystemService: systemService, - Cache: xcache.NewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), + Cache: xcache.MustNewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}), } liveStreamRegistry := biz.NewLiveStreamRegistry() diff --git a/internal/server/orchestrator/lb_strategies_test.go b/internal/server/orchestrator/lb_strategies_test.go index ce122751a..7dc3c0bb4 100644 --- a/internal/server/orchestrator/lb_strategies_test.go +++ b/internal/server/orchestrator/lb_strategies_test.go @@ -2,12 +2,14 @@ package orchestrator import ( "context" + "testing" "github.com/zhenzou/executors" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/pkg/xcache" "github.com/looplj/axonhub/internal/server/biz" + "github.com/stretchr/testify/require" ) // mockStrategy is a test strategy that returns a fixed score. @@ -90,11 +92,14 @@ func (m *mockTraceProvider) GetLastSuccessfulChannelID(ctx context.Context, trac // newTestChannelService creates a minimal channel service for testing. // It bypasses the normal initialization to avoid requiring a ScheduledExecutor. -func newTestChannelService(client *ent.Client) *biz.ChannelService { - systemService := biz.NewSystemService(biz.SystemServiceParams{ +func newTestChannelService(t *testing.T, client *ent.Client) *biz.ChannelService { + t.Helper() + + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, Ent: client, }) + require.NoError(t, err) return biz.NewChannelService(biz.ChannelServiceParams{ Executor: executors.NewPoolScheduleExecutor(), @@ -104,17 +109,21 @@ func newTestChannelService(client *ent.Client) *biz.ChannelService { } // newTestRequestService creates a minimal request service for testing. -func newTestRequestService(client *ent.Client) *biz.RequestService { - systemService := biz.NewSystemService(biz.SystemServiceParams{ +func newTestRequestService(t *testing.T, client *ent.Client) *biz.RequestService { + t.Helper() + + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ CacheConfig: xcache.Config{}, Ent: client, }) - dataStorageService := biz.NewDataStorageService(biz.DataStorageServiceParams{ + require.NoError(t, err) + dataStorageService, err := biz.NewDataStorageService(biz.DataStorageServiceParams{ Client: client, SystemService: systemService, CacheConfig: xcache.Config{}, Executor: executors.NewPoolScheduleExecutor(), }) + require.NoError(t, err) channelService := biz.NewChannelServiceForTest(client) usageLogService := biz.NewUsageLogService(client, systemService, channelService) diff --git a/internal/server/orchestrator/lb_strategy_bp_test.go b/internal/server/orchestrator/lb_strategy_bp_test.go index 193f044d2..153557c0b 100644 --- a/internal/server/orchestrator/lb_strategy_bp_test.go +++ b/internal/server/orchestrator/lb_strategy_bp_test.go @@ -108,7 +108,7 @@ func TestErrorAwareStrategy_Score_ConsecutiveFailures(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record consecutive failures for range 3 { @@ -149,7 +149,7 @@ func TestErrorAwareStrategy_Score_RecentSuccess(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record a recent success perf := &biz.PerformanceRecord{ @@ -259,7 +259,7 @@ func TestConnectionAwareStrategy_Name(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) tracker := NewDefaultConnectionTracker(10) strategy := NewConnectionAwareStrategy(channelService, tracker) assert.Equal(t, "ConnectionAware", strategy.Name()) @@ -271,7 +271,7 @@ func TestConnectionAwareStrategy_Score_NoTracker(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) strategy := NewConnectionAwareStrategy(channelService, nil) channel := &biz.Channel{ @@ -288,7 +288,7 @@ func TestConnectionAwareStrategy_Score_NoConnections(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) tracker := NewDefaultConnectionTracker(10) strategy := NewConnectionAwareStrategy(channelService, tracker) @@ -306,7 +306,7 @@ func TestConnectionAwareStrategy_Score_PartialUtilization(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) tracker := NewDefaultConnectionTracker(10) strategy := NewConnectionAwareStrategy(channelService, tracker) @@ -329,7 +329,7 @@ func TestConnectionAwareStrategy_Score_FullUtilization(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) tracker := NewDefaultConnectionTracker(10) strategy := NewConnectionAwareStrategy(channelService, tracker) @@ -352,7 +352,7 @@ func TestConnectionAwareStrategy_ScoreConsistency(t *testing.T) { client := enttest.NewEntClient(t, "sqlite3", "file:ent?mode=memory&_fk=0") defer client.Close() - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) testCases := []struct { name string diff --git a/internal/server/orchestrator/lb_strategy_rate_limit.go b/internal/server/orchestrator/lb_strategy_rate_limit.go index f7ae58432..b68f45d75 100644 --- a/internal/server/orchestrator/lb_strategy_rate_limit.go +++ b/internal/server/orchestrator/lb_strategy_rate_limit.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "fmt" "time" "github.com/looplj/axonhub/internal/log" @@ -14,6 +15,13 @@ import ( // so that exhausted channels always rank last, while still remaining as fallback candidates. const rateLimitExhaustedScore = -10000 +// ErrRateLimitExhausted is returned when all available channels are rate-limited or in cooldown. +var ErrRateLimitExhausted = fmt.Errorf("all channels are rate-limited or in cooldown") + +// rateLimitBlockThreshold defines the score below which a channel is considered blocked. +// Channels scoring at or below this threshold will prevent routing entirely. +const rateLimitBlockThreshold = rateLimitExhaustedScore + // RateLimitAwareStrategy adjusts channel scores based on configured RPM/TPM rate limits and concurrency limits. // Channels that have exhausted their rate limits receive a heavily negative score to be ranked last. type RateLimitAwareStrategy struct { @@ -138,6 +146,51 @@ func (s *RateLimitAwareStrategy) Score(ctx context.Context, channel *biz.Channel return score } +// IsExhausted returns true if the channel is rate-limited or in cooldown and should be blocked from routing. +func (s *RateLimitAwareStrategy) IsExhausted(channel *biz.Channel) bool { + if s.requestTracker.IsCoolingDown(channel.ID) { + return true + } + + settings := channel.Settings + if settings == nil || settings.RateLimit == nil { + if s.connectionTracker != nil { + if concurrencyLimit, _, _ := s.resolveConcurrencyLimit(channel); concurrencyLimit > 0 { + concurrent := s.connectionTracker.GetActiveConnections(channel.ID) + if int64(concurrent) >= concurrencyLimit { + return true + } + } + } + return false + } + + rl := settings.RateLimit + + if rl.RPM != nil && *rl.RPM > 0 { + if s.requestTracker.GetRequestCount(channel.ID) >= *rl.RPM { + return true + } + } + + if rl.TPM != nil && *rl.TPM > 0 { + if s.requestTracker.GetTokenCount(channel.ID) >= *rl.TPM { + return true + } + } + + if s.connectionTracker != nil { + if concurrencyLimit, _, _ := s.resolveConcurrencyLimit(channel); concurrencyLimit > 0 { + concurrent := s.connectionTracker.GetActiveConnections(channel.ID) + if int64(concurrent) >= concurrencyLimit { + return true + } + } + } + + return false +} + // ScoreWithDebug calculates the score with detailed debug information. func (s *RateLimitAwareStrategy) ScoreWithDebug(ctx context.Context, channel *biz.Channel) (float64, StrategyScore) { startTime := time.Now() diff --git a/internal/server/orchestrator/lb_strategy_rr_test.go b/internal/server/orchestrator/lb_strategy_rr_test.go index 0eb86bc3f..a2cc1c320 100644 --- a/internal/server/orchestrator/lb_strategy_rr_test.go +++ b/internal/server/orchestrator/lb_strategy_rr_test.go @@ -337,7 +337,7 @@ func TestRoundRobinStrategy_WithRealDatabase(t *testing.T) { channels[i] = ch } - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record different numbers of requests for each channel // IncrementChannelSelection is called at selection time in production, diff --git a/internal/server/orchestrator/lb_strategy_trace_test.go b/internal/server/orchestrator/lb_strategy_trace_test.go index ace8b85f9..4516c8afb 100644 --- a/internal/server/orchestrator/lb_strategy_trace_test.go +++ b/internal/server/orchestrator/lb_strategy_trace_test.go @@ -130,7 +130,7 @@ func TestTraceAwareStrategy_Score_WithLastSuccessChannel(t *testing.T) { ctx = contexts.WithTrace(ctx, &ent.Trace{ID: trace.ID}) ctx = ent.NewContext(ctx, client) - requestService := newTestRequestService(client) + requestService := newTestRequestService(t, client) strategy := NewTraceAwareStrategy(requestService) channel := &biz.Channel{Channel: ch} @@ -189,7 +189,7 @@ func TestTraceAwareStrategy_Score_DifferentChannel(t *testing.T) { ctx = contexts.WithTrace(ctx, &ent.Trace{ID: trace.ID}) - requestService := newTestRequestService(client) + requestService := newTestRequestService(t, client) strategy := NewTraceAwareStrategy(requestService) // Test with ch2 (different channel) diff --git a/internal/server/orchestrator/live_streaming.go b/internal/server/orchestrator/live_streaming.go index 784c68995..0e1025a84 100644 --- a/internal/server/orchestrator/live_streaming.go +++ b/internal/server/orchestrator/live_streaming.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "sync" "github.com/looplj/axonhub/internal/pkg/chunkbuffer" "github.com/looplj/axonhub/internal/server/biz" @@ -18,7 +19,7 @@ type livePreviewMiddleware struct { systemService *biz.SystemService liveStreamRegistry *biz.LiveStreamRegistry enabled bool - initialized bool + once sync.Once } func withLivePreview(state *PersistenceState, systemService *biz.SystemService, liveStreamRegistry *biz.LiveStreamRegistry) pipeline.Middleware { @@ -44,10 +45,9 @@ func (m *livePreviewMiddleware) OnInboundLlmRequest(ctx context.Context, request return request, nil } - if !m.initialized { + m.once.Do(func() { m.enabled = m.systemService != nil && m.systemService.StoragePolicyOrDefault(ctx).LivePreview - m.initialized = true - } + }) return request, nil } diff --git a/internal/server/orchestrator/live_streaming_test.go b/internal/server/orchestrator/live_streaming_test.go index 0166b1179..3c4516885 100644 --- a/internal/server/orchestrator/live_streaming_test.go +++ b/internal/server/orchestrator/live_streaming_test.go @@ -25,7 +25,6 @@ func TestLivePreviewMiddleware_OnInboundLlmRequest_DisablesPreviewForNonStreamin middleware := &livePreviewMiddleware{ enabled: true, - initialized: true, } _, err := middleware.OnInboundLlmRequest(ctx, &llm.Request{Stream: &nonStreaming}) @@ -39,12 +38,12 @@ func TestLivePreviewMiddleware_OnInboundLlmRequest_DisablesPreviewForNonStreamin middleware := &livePreviewMiddleware{ liveStreamRegistry: biz.NewLiveStreamRegistry(), enabled: true, - initialized: true, } _, err := middleware.OnInboundLlmRequest(ctx, &llm.Request{Stream: &streaming}) require.NoError(t, err) - require.True(t, middleware.enabled) + // systemService is nil, so once.Do sets enabled = false + require.False(t, middleware.enabled) }) } diff --git a/internal/server/orchestrator/load_balancer.go b/internal/server/orchestrator/load_balancer.go index 3a26e4d03..5002b6850 100644 --- a/internal/server/orchestrator/load_balancer.go +++ b/internal/server/orchestrator/load_balancer.go @@ -206,6 +206,13 @@ func (lb *LoadBalancer) sortProduction(ctx context.Context, candidates []*Channe // Extract top k sorted candidates result := lo.Map(scored[:topK], func(ch candidateScore, _ int) *ChannelModelsCandidate { return ch.candidate }) + // Check if the top candidate is rate-limit exhausted; if so, block routing. + if len(result) > 0 && result[0] != nil && result[0].Channel != nil { + if lb.isRateLimitExhausted(result[0].Channel) { + return nil + } + } + // Increment selection count for the top candidate to ensure subsequent // concurrent requests see the updated count and select different channels if len(result) > 0 && result[0] != nil && result[0].Channel != nil && lb.selectionTracker != nil { @@ -285,6 +292,13 @@ func (lb *LoadBalancer) sortWithDebug(ctx context.Context, candidates []*Channel return nil }) + // Check if the top candidate is rate-limit exhausted; if so, block routing. + if len(result) > 0 && result[0] != nil && result[0].Channel != nil { + if lb.isRateLimitExhausted(result[0].Channel) { + return nil + } + } + // Increment selection count for the top candidate to ensure subsequent // concurrent requests see the updated count and select different channels if len(result) > 0 && result[0] != nil && result[0].Channel != nil && lb.selectionTracker != nil { @@ -294,6 +308,17 @@ func (lb *LoadBalancer) sortWithDebug(ctx context.Context, candidates []*Channel return result } +// isRateLimitExhausted checks if a channel's rate limit strategy reports exhaustion. +// Returns true only if a RateLimitAwareStrategy is registered and reports the channel as exhausted. +func (lb *LoadBalancer) isRateLimitExhausted(channel *biz.Channel) bool { + for _, strategy := range lb.strategies { + if rl, ok := strategy.(*RateLimitAwareStrategy); ok { + return rl.IsExhausted(channel) + } + } + return false +} + // calculateTopK determines how many candidates to select based on retry policy. func (lb *LoadBalancer) calculateTopK(ctx context.Context, candidates []*ChannelModelsCandidate) int { retryPolicy := lb.systemService.RetryPolicyOrDefault(ctx) diff --git a/internal/server/orchestrator/load_balancer_test.go b/internal/server/orchestrator/load_balancer_test.go index 68607671f..1af88d028 100644 --- a/internal/server/orchestrator/load_balancer_test.go +++ b/internal/server/orchestrator/load_balancer_test.go @@ -284,7 +284,7 @@ func TestLoadBalancer_ErrorAware_ChannelWithErrorsRankedLower(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record consecutive failures for ch2 for range 3 { @@ -364,7 +364,7 @@ func TestLoadBalancer_ErrorAware_ShortTermErrorPenalty(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record a recent failure for ch1 (within cooldown period) perf := &biz.PerformanceRecord{ @@ -473,7 +473,7 @@ func TestLoadBalancer_TraceAware_SameChannelPrioritized(t *testing.T) { ctx = contexts.WithTrace(ctx, trace) // Use the trace entity directly ctx = ent.NewContext(ctx, client) - requestService := newTestRequestService(client) + requestService := newTestRequestService(t, client) traceStrategy := NewTraceAwareStrategy(requestService) weightStrategy := NewWeightStrategy() // Mock SystemService for testing @@ -538,7 +538,7 @@ func TestLoadBalancer_Combined_ErrorAndTrace(t *testing.T) { Save(ctx) require.NoError(t, err) - channelService := newTestChannelService(client) + channelService := newTestChannelService(t, client) // Record consecutive failures for ch2 for range 2 { @@ -576,7 +576,7 @@ func TestLoadBalancer_Combined_ErrorAndTrace(t *testing.T) { ctx = ent.NewContext(ctx, client) // Create load balancer with both strategies - requestService := newTestRequestService(client) + requestService := newTestRequestService(t, client) traceStrategy := NewTraceAwareStrategy(requestService) errorStrategy := NewErrorAwareStrategy(channelService) weightStrategy := NewWeightStrategy() diff --git a/internal/server/orchestrator/orchestrator.go b/internal/server/orchestrator/orchestrator.go index 325d6c33d..e318fc8a0 100644 --- a/internal/server/orchestrator/orchestrator.go +++ b/internal/server/orchestrator/orchestrator.go @@ -141,7 +141,10 @@ type ChatCompletionResult struct { func (processor *ChatCompletionOrchestrator) Process(ctx context.Context, request *httpclient.Request) (ChatCompletionResult, error) { // The context is system bypassed to allow the orchestrator to access the system settings. - ctx = authz.WithSystemBypass(ctx, "process-chat-completion") + ctx, err := authz.WithSystemBypass(ctx, "process-chat-completion") + if err != nil { + return ChatCompletionResult{}, err + } apiKey, _ := contexts.GetAPIKey(ctx) diff --git a/internal/server/orchestrator/outbound.go b/internal/server/orchestrator/outbound.go index 18e698c47..3c353220a 100644 --- a/internal/server/orchestrator/outbound.go +++ b/internal/server/orchestrator/outbound.go @@ -11,11 +11,12 @@ import ( "github.com/looplj/axonhub/internal/pkg/xcontext" "github.com/looplj/axonhub/internal/server/biz" "github.com/looplj/axonhub/llm" - "github.com/looplj/axonhub/llm/httpclient" "github.com/looplj/axonhub/llm/pipeline" + "github.com/looplj/axonhub/llm/httpclient" "github.com/looplj/axonhub/llm/streams" "github.com/looplj/axonhub/llm/transformer" "github.com/looplj/axonhub/llm/transformer/shared" + "sync/atomic" ) // OutboundPersistentStream wraps a stream and tracks all responses for final saving to database. @@ -35,7 +36,7 @@ type OutboundPersistentStream struct { transformer transformer.Outbound perf *biz.PerformanceRecord responseChunks []*httpclient.StreamEvent - closed bool + closed atomic.Bool state *PersistenceState } @@ -62,7 +63,6 @@ func NewOutboundPersistentStream( transformer: outboundTransformer, perf: perf, responseChunks: make([]*httpclient.StreamEvent, 0), - closed: false, state: state, } @@ -93,11 +93,11 @@ func (ts *OutboundPersistentStream) Err() error { } func (ts *OutboundPersistentStream) Close() error { - if ts.closed { + if ts.closed.Load() { return nil } - ts.closed = true + ts.closed.Store(true) ctx := ts.ctx log.Debug(ctx, "Closing persistent stream", log.Int("chunk_count", len(ts.responseChunks)), log.Bool("received_done", ts.state.StreamCompleted)) diff --git a/internal/server/orchestrator/pass_through.go b/internal/server/orchestrator/pass_through.go index b9300915e..aa912699b 100644 --- a/internal/server/orchestrator/pass_through.go +++ b/internal/server/orchestrator/pass_through.go @@ -246,6 +246,8 @@ func captureRawProviderStream(outbound *PersistentOutboundTransformer) pipeline. return } + // Use select with default to prevent deadlock when the pass-through + // consumer hasn't started reading yet. One channel won't block the other. select { case rawStreamCh <- event: case <-attemptCtx.Done(): @@ -253,6 +255,7 @@ func captureRawProviderStream(outbound *PersistentOutboundTransformer) pipeline. log.String("channel", channel.Name)) return + default: } } }() diff --git a/internal/server/orchestrator/pass_through_test.go b/internal/server/orchestrator/pass_through_test.go index 6479976e7..c1adb44c3 100644 --- a/internal/server/orchestrator/pass_through_test.go +++ b/internal/server/orchestrator/pass_through_test.go @@ -1007,10 +1007,11 @@ func TestApplyUserAgentPassThrough(t *testing.T) { ctx, client := setupTest(t) // Create real system service with test database - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) // Set global User-Agent pass-through setting - err := systemService.SetUserAgentPassThrough(ctx, tt.globalUAEnabled) + err = systemService.SetUserAgentPassThrough(ctx, tt.globalUAEnabled) require.NoError(t, err) // Create mock channel with optional pass-through setting @@ -1078,7 +1079,8 @@ func TestApplyUserAgentPassThrough_NoChannel(t *testing.T) { ctx, client := setupTest(t) // Create real system service with test database - systemService := newTestSystemService(client) + systemService, err := newTestSystemService(t, client) + require.NoError(t, err) // Create outbound without a channel outbound := &PersistentOutboundTransformer{ diff --git a/internal/server/orchestrator/performance.go b/internal/server/orchestrator/performance.go index 267bdb99a..4bb89038f 100644 --- a/internal/server/orchestrator/performance.go +++ b/internal/server/orchestrator/performance.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "errors" + "sync" "time" "github.com/looplj/axonhub/internal/contexts" @@ -141,6 +142,7 @@ type recordPerformanceStream struct { stream streams.Stream[*llm.Response] state *PersistenceState + mu sync.Mutex firstTokenSet bool reasoningStartSet bool reasoningEndSet bool @@ -152,6 +154,8 @@ func (s *recordPerformanceStream) Current() *llm.Response { return event } + s.mu.Lock() + defer s.mu.Unlock() if !s.firstTokenSet && s.state.Perf != nil { s.state.Perf.MarkFirstToken() s.firstTokenSet = true diff --git a/internal/server/orchestrator/request_test.go b/internal/server/orchestrator/request_test.go index 3e8fdbd83..7aa23fe9d 100644 --- a/internal/server/orchestrator/request_test.go +++ b/internal/server/orchestrator/request_test.go @@ -100,9 +100,10 @@ func TestPersistRequestMiddleware_UsageExtraction_EmbeddingResponse(t *testing.T } channelService := biz.NewChannelServiceForTest(client) - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ Ent: client, }) + require.NoError(t, err) usageLogService := biz.NewUsageLogService(client, systemService, channelService) state.UsageLogService = usageLogService @@ -171,9 +172,10 @@ func TestPersistRequestMiddleware_UsageExtraction_ChatResponse(t *testing.T) { } channelService := biz.NewChannelServiceForTest(client) - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ Ent: client, }) + require.NoError(t, err) usageLogService := biz.NewUsageLogService(client, systemService, channelService) state.UsageLogService = usageLogService @@ -244,9 +246,10 @@ func TestPersistRequestMiddleware_UsageExtraction_NilUsage(t *testing.T) { } channelService := biz.NewChannelServiceForTest(client) - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ Ent: client, }) + require.NoError(t, err) usageLogService := biz.NewUsageLogService(client, systemService, channelService) state.UsageLogService = usageLogService @@ -309,9 +312,10 @@ func TestPersistRequestMiddleware_UsageExtraction_EmbeddingWithNilUsage(t *testi } channelService := biz.NewChannelServiceForTest(client) - systemService := biz.NewSystemService(biz.SystemServiceParams{ + systemService, err := biz.NewSystemService(biz.SystemServiceParams{ Ent: client, }) + require.NoError(t, err) usageLogService := biz.NewUsageLogService(client, systemService, channelService) state.UsageLogService = usageLogService diff --git a/internal/server/video_storage/worker.go b/internal/server/video_storage/worker.go index c421fd631..6d074a869 100644 --- a/internal/server/video_storage/worker.go +++ b/internal/server/video_storage/worker.go @@ -59,7 +59,10 @@ func (w *Worker) Start(ctx context.Context) error { return nil } - ctx = authz.WithSystemBypass(ctx, "video-storage-start") + ctx, err := authz.WithSystemBypass(ctx, "video-storage-start") + if err != nil { + return err + } settings, err := w.systemService.VideoStorageSettings(ctx) if err != nil { return err @@ -95,7 +98,11 @@ func (w *Worker) Stop(ctx context.Context) error { } func (w *Worker) runScanWithSystemContext(ctx context.Context) { - ctx = authz.WithSystemBypass(ctx, "video-storage-scan") + ctx, err := authz.WithSystemBypass(ctx, "video-storage-scan") + if err != nil { + log.Error(context.Background(), "failed to create bypass context", log.Cause(err)) + return + } ctx = ent.NewContext(ctx, w.ent) ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) diff --git a/llm/go.mod b/llm/go.mod index 3ace7f732..bacdacffb 100644 --- a/llm/go.mod +++ b/llm/go.mod @@ -27,7 +27,6 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/aptible/supercronic v0.2.32 // indirect github.com/aws/smithy-go v1.24.3 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dubbogo/gost v1.14.0 // indirect @@ -38,7 +37,6 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/k0kubun/pp v3.0.1+incompatible // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -53,12 +51,10 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect @@ -66,7 +62,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/llm/go.sum b/llm/go.sum index 169ddef86..8a42c2eab 100644 --- a/llm/go.sum +++ b/llm/go.sum @@ -78,13 +78,9 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -278,10 +274,7 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= @@ -583,31 +576,17 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -636,8 +615,6 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -714,8 +691,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -724,8 +699,6 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -741,8 +714,6 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -805,10 +776,6 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -818,8 +785,6 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -888,9 +853,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -950,11 +914,6 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -978,8 +937,6 @@ google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -995,8 +952,6 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= diff --git a/llm/httpclient/builder.go b/llm/httpclient/builder.go index 4c8bbb864..234ced7c2 100644 --- a/llm/httpclient/builder.go +++ b/llm/httpclient/builder.go @@ -2,6 +2,7 @@ package httpclient import ( "encoding/json" + "fmt" "net/http" ) @@ -48,7 +49,7 @@ func (rb *RequestBuilder) WithHeaders(headers map[string]string) *RequestBuilder } // WithBody sets the request body. -func (rb *RequestBuilder) WithBody(body any) *RequestBuilder { +func (rb *RequestBuilder) WithBody(body any) (*RequestBuilder, error) { switch v := body.(type) { case []byte: rb.request.Body = v @@ -57,13 +58,13 @@ func (rb *RequestBuilder) WithBody(body any) *RequestBuilder { default: b, err := json.Marshal(v) if err != nil { - panic(err) + return rb, fmt.Errorf("json marshal failed: %w", err) } rb.request.Body = b } - return rb + return rb, nil } // WithAuth sets authentication. diff --git a/llm/httpclient/client.go b/llm/httpclient/client.go index 8b2775e89..36cd13c02 100644 --- a/llm/httpclient/client.go +++ b/llm/httpclient/client.go @@ -16,6 +16,12 @@ import ( "github.com/looplj/axonhub/llm/streams" ) +// Default timeout values for HTTP client operations. +const ( + DefaultResponseHeaderTimeout = 60 * time.Second + DefaultRequestTimeout = 300 * time.Second +) + // HttpClient implements the HttpClient interface. type HttpClient struct { client *http.Client @@ -55,6 +61,7 @@ func NewHttpClientWithProxy(proxyConfig *ProxyConfig, opts ...ClientOption) *Htt IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: DefaultResponseHeaderTimeout, } if options.insecureSkipVerify { @@ -137,38 +144,43 @@ func NewHttpClient(opts ...ClientOption) *HttpClient { opt(&options) } - client := &http.Client{} + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: DefaultResponseHeaderTimeout, + } + if options.insecureSkipVerify { - var transport *http.Transport + var baseTransport *http.Transport if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { - transport = defaultTransport.Clone() + baseTransport = defaultTransport.Clone() } else { - // Fall back to a transport close to http.DefaultTransport when it has been replaced. - transport = (&http.Transport{ - Proxy: getProxyFunc(nil), - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }) + baseTransport = transport } - if transport.TLSClientConfig == nil { - transport.TLSClientConfig = &tls.Config{} + if baseTransport.TLSClientConfig == nil { + baseTransport.TLSClientConfig = &tls.Config{} } else { - transport.TLSClientConfig = transport.TLSClientConfig.Clone() + baseTransport.TLSClientConfig = baseTransport.TLSClientConfig.Clone() + } + baseTransport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // User-configured option for self-signed certificates + + return &HttpClient{ + client: &http.Client{Transport: baseTransport}, + opts: opts, } - transport.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec // User-configured option for self-signed certificates - client.Transport = transport } return &HttpClient{ - client: client, + client: &http.Client{Transport: transport}, opts: opts, } } @@ -182,6 +194,12 @@ func NewHttpClientWithClient(client *http.Client) *HttpClient { // Do executes the HTTP request. func (hc *HttpClient) Do(ctx context.Context, request *Request) (*Response, error) { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, DefaultRequestTimeout) + defer cancel() + } + slog.DebugContext(ctx, "execute http request", slog.Any("request", request), slog.Any("proxy", hc.proxyConfig)) rawReq, err := hc.BuildHttpRequest(ctx, request) @@ -203,7 +221,8 @@ func (hc *HttpClient) Do(ctx context.Context, request *Request) (*Response, erro } }() - body, err := io.ReadAll(rawResp.Body) + const maxResponseBodySize = 100 * 1024 * 1024 // 100MB + body, err := io.ReadAll(io.LimitReader(rawResp.Body, maxResponseBodySize)) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } @@ -214,7 +233,7 @@ func (hc *HttpClient) Do(ctx context.Context, request *Request) (*Response, erro slog.String("method", rawReq.Method), slog.String("url", rawReq.URL.String()), slog.Int("status_code", rawResp.StatusCode), - slog.String("body", string(body))) + slog.String("body", truncateBody(body))) } return nil, &Error{ @@ -232,7 +251,7 @@ func (hc *HttpClient) Do(ctx context.Context, request *Request) (*Response, erro slog.String("method", rawReq.Method), slog.String("url", rawReq.URL.String()), slog.Int("status_code", rawResp.StatusCode), - slog.String("body", string(body))) + slog.String("body", truncateBody(body))) } // Build generic response @@ -289,7 +308,7 @@ func (hc *HttpClient) DoStream(ctx context.Context, request *Request) (streams.S slog.String("method", rawReq.Method), slog.String("url", rawReq.URL.String()), slog.Int("status_code", rawResp.StatusCode), - slog.String("body", string(body))) + slog.String("body", truncateBody(body))) } return nil, &Error{ @@ -404,6 +423,17 @@ func applyAuth(headers http.Header, auth *AuthConfig) error { return nil } +// truncateBody limits the response body logged at debug level to prevent +// leaking large or sensitive payloads. Bodies longer than 512 bytes are +// truncated with an ellipsis suffix. +func truncateBody(body []byte) string { + const maxLen = 512 + if len(body) <= maxLen { + return string(body) + } + return string(body[:maxLen]) + "... (truncated)" +} + // extractHeaders extracts headers from HTTP response. func (hc *HttpClient) extractHeaders(headers http.Header) map[string]string { result := make(map[string]string) diff --git a/llm/httpclient/client_test.go b/llm/httpclient/client_test.go index 28586cb16..11fe17525 100644 --- a/llm/httpclient/client_test.go +++ b/llm/httpclient/client_test.go @@ -1,6 +1,7 @@ package httpclient import ( + "context" "fmt" "io" "net/http" @@ -602,7 +603,8 @@ data: [DONE] body := io.NopCloser(strings.NewReader(sseData)) stream := &defaultSSEDecoder{ - ctx: t.Context(), + done: t.Context().Done(), + cancel: func() {}, sseStream: sse.NewStream(body), } @@ -701,3 +703,91 @@ func TestBuildHttpRequest_UserAgentPassThrough(t *testing.T) { }) } } + +// TestHttpClient_Timeout_NonStreaming verifies that non-streaming requests +// with a short context deadline time out when the server does not respond. +func TestHttpClient_Timeout_NonStreaming(t *testing.T) { + // Server that never responds (hangs indefinitely) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() // Block until context is cancelled + })) + defer server.Close() + + client := NewHttpClient() + req := &Request{ + Method: http.MethodGet, + URL: server.URL, + } + + // Use a short timeout that is much less than DefaultRequestTimeout + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + + _, err := client.Do(ctx, req) + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP request failed") +} + +// TestHttpClient_Timeout_ContextRespected verifies that when a context +// with a deadline is passed, the Do() method uses the caller's deadline +// instead of applying the default timeout. +func TestHttpClient_Timeout_ContextRespected(t *testing.T) { + // Server that responds quickly + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + client := NewHttpClient() + req := &Request{ + Method: http.MethodGet, + URL: server.URL, + } + + // Pass a context with an explicit deadline (well beyond what the server needs) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + resp, err := client.Do(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +// TestNewHttpClient_DefaultTransport verifies that in non-InsecureSkipVerify +// mode, NewHttpClient returns a client with a properly configured Transport +// (not a zero-config http.Client). +func TestNewHttpClient_DefaultTransport(t *testing.T) { + client := NewHttpClient() + nativeClient := client.GetNativeClient() + + // Transport must not be nil + require.NotNil(t, nativeClient.Transport, "Transport should not be nil (zero-config)") + + transport, ok := nativeClient.Transport.(*http.Transport) + require.True(t, ok, "Transport should be *http.Transport") + + // ResponseHeaderTimeout should be set + require.Equal(t, DefaultResponseHeaderTimeout, transport.ResponseHeaderTimeout, + "ResponseHeaderTimeout should be set to default value") + + // Proxy should be configured + require.NotNil(t, transport.Proxy, "Proxy should be configured") + + // DialContext should be configured + require.NotNil(t, transport.DialContext, "DialContext should be configured") +} + +// TestNewHttpClientWithProxy_TimeoutSettings verifies that NewHttpClientWithProxy +// sets ResponseHeaderTimeout on the transport. +func TestNewHttpClientWithProxy_TimeoutSettings(t *testing.T) { + client := NewHttpClientWithProxy(nil) + nativeClient := client.GetNativeClient() + + transport, ok := nativeClient.Transport.(*http.Transport) + require.True(t, ok, "Transport should be *http.Transport") + + require.Equal(t, DefaultResponseHeaderTimeout, transport.ResponseHeaderTimeout, + "ResponseHeaderTimeout should be set to default value") +} diff --git a/llm/httpclient/decoder.go b/llm/httpclient/decoder.go index 4d5eca172..d1431751d 100644 --- a/llm/httpclient/decoder.go +++ b/llm/httpclient/decoder.go @@ -10,7 +10,7 @@ import ( "github.com/tmaxmax/go-sse" ) -// decoderRegistry holds registered stream decoders. +// decoderRegistry holds registered stream decodings. type decoderRegistry struct { mu sync.RWMutex decoders map[string]StreamDecoderFactory @@ -41,10 +41,10 @@ func GetDecoder(contentType string) (StreamDecoderFactory, bool) { // NewDefaultSSEDecoder creates a new default SSE decoder. func NewDefaultSSEDecoder(ctx context.Context, rc io.ReadCloser) StreamDecoder { + ctx, cancel := context.WithCancel(ctx) return &defaultSSEDecoder{ - ctx: ctx, - // sseStream: sse.NewStream(rc), - // 图片生成需要大量数据,设置最大事件大小 + done: ctx.Done(), + cancel: cancel, sseStream: sse.NewStreamWithConfig(rc, &sse.StreamConfig{ MaxEventSize: 32 * 1024 * 1024, }), @@ -55,10 +55,9 @@ func NewDefaultSSEDecoder(ctx context.Context, rc io.ReadCloser) StreamDecoder { var _ StreamDecoder = (*defaultSSEDecoder)(nil) // defaultSSEDecoder implements streams.Stream for Server-Sent Events using go-sse Stream. -// -//nolint:containedctx // Checked. type defaultSSEDecoder struct { - ctx context.Context + done <-chan struct{} + cancel context.CancelFunc sseStream *sse.Stream current *StreamEvent err error @@ -81,42 +80,49 @@ func (s *defaultSSEDecoder) Next() bool { // Check context cancellation select { - case <-s.ctx.Done(): - slog.DebugContext(s.ctx, "SSE stream closed") - - s.err = s.ctx.Err() + case <-s.done: + s.err = context.Canceled _ = s.Close() return false default: } - // Receive next event from go-sse Stream - event, err := s.sseStream.Recv() - if err != nil { - if errors.Is(err, io.EOF) { - slog.DebugContext(s.ctx, "SSE stream closed") - _ = s.Close() - - return false - } + // Receive next event from go-sse Stream. + // Recv() blocks on I/O, so we run it in a goroutine and select + // against s.done to allow context cancellation to interrupt. + type recvResult struct { + event sse.Event + err error + } + ch := make(chan recvResult, 1) + go func() { + event, err := s.sseStream.Recv() + ch <- recvResult{event: event, err: err} + }() - s.err = err + select { + case <-s.done: + s.err = context.Canceled _ = s.Close() - return false + case result := <-ch: + if result.err != nil { + if errors.Is(result.err, io.EOF) { + _ = s.Close() + return false + } + s.err = result.err + _ = s.Close() + return false + } + s.current = &StreamEvent{ + LastEventID: result.event.LastEventID, + Type: result.event.Type, + Data: []byte(result.event.Data), + } + return true } - - slog.DebugContext(s.ctx, "SSE event received", slog.Any("event", event)) - - // Create stream event for this event - s.current = &StreamEvent{ - LastEventID: event.LastEventID, - Type: event.Type, - Data: []byte(event.Data), - } - - return true } // Current returns the current event data. @@ -137,9 +143,12 @@ func (s *defaultSSEDecoder) Close() error { } s.closed = true + if s.cancel != nil { + s.cancel() + } if s.sseStream != nil { s.closeErr = s.sseStream.Close() - slog.DebugContext(s.ctx, "SSE stream closed") + slog.Debug("SSE stream closed") } return s.closeErr diff --git a/llm/httpclient/model.go b/llm/httpclient/model.go index 4aaec3c38..b8900a15d 100644 --- a/llm/httpclient/model.go +++ b/llm/httpclient/model.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "net/http" "net/url" @@ -58,6 +59,25 @@ type Request struct { } +// LogValue implements slog.LogValuer so that debug logging of Request +// does not leak authentication credentials. +func (r *Request) LogValue() slog.Value { + return slog.GroupValue( + slog.String("method", r.Method), + slog.String("url", r.URL), + slog.String("path", r.Path), + slog.Any("query", r.Query), + slog.Any("headers", r.Headers), + slog.String("content_type", r.ContentType), + slog.Int("body_len", len(r.Body)), + slog.Int("json_body_len", len(r.JSONBody)), + slog.Any("auth", r.Auth), // AuthConfig has its own LogValue + slog.String("request_id", r.RequestID), + slog.String("client_ip", r.ClientIP), + slog.String("request_type", r.RequestType), + slog.String("api_format", r.APIFormat), + ) +} // AuthConfig represents authentication configuration. type AuthConfig struct { @@ -72,6 +92,27 @@ type AuthConfig struct { HeaderKey string `json:"header_key,omitempty"` } +// LogValue implements slog.LogValuer so that debug logging of AuthConfig +// masks the API key and does not leak credentials. +func (a *AuthConfig) LogValue() slog.Value { + return slog.GroupValue( + slog.String("type", a.Type), + slog.String("api_key", maskKey(a.APIKey)), + slog.String("header_key", a.HeaderKey), + ) +} + +// maskKey returns a redacted version of an API key for safe logging. +func maskKey(key string) string { + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + return key[:4] + "****" + key[len(key)-4:] +} + const ( AuthTypeBearer = "bearer" AuthTypeAPIKey = "api_key" diff --git a/llm/httpclient/utils.go b/llm/httpclient/utils.go index 30ca27798..cb6e420c2 100644 --- a/llm/httpclient/utils.go +++ b/llm/httpclient/utils.go @@ -239,22 +239,32 @@ func FinalizeAuthHeaders(req *Request) (*Request, error) { // If a header is in the mergeWithAppendHeaders list, it adds non-duplicate values from the source. // Otherwise, it overwrites the destination header with the source values. // Blocked, sensitive, and library-managed headers are not merged. +// Returns a new map to avoid data races when dest is shared across goroutines. func MergeHTTPHeaders(dest, src http.Header) http.Header { + if len(src) == 0 { + return dest + } + + result := make(http.Header, len(dest)+len(src)) + for k, v := range dest { + result[k] = v + } + for k, v := range src { if sensitiveHeaders[k] || libManagedHeaders[k] || isBlockedHeader(k) { continue } if mergeWithAppendHeaders[k] { - if existingValues, ok := dest[k]; ok { - dest[k] = lo.Uniq(append(existingValues, v...)) + if existingValues, ok := result[k]; ok { + result[k] = lo.Uniq(append(existingValues, v...)) } else { - dest[k] = v + result[k] = v } } else { - dest[k] = v + result[k] = v } } - return dest + return result } diff --git a/llm/oauth/device_flow_provider.go b/llm/oauth/device_flow_provider.go index b18d68148..e428fce82 100644 --- a/llm/oauth/device_flow_provider.go +++ b/llm/oauth/device_flow_provider.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/url" + "regexp" "strings" "sync" "time" @@ -362,7 +363,7 @@ func (p *DeviceFlowProvider) getAccessTokenWithRefresh(ctx context.Context) (str if onRefreshed != nil { if err := onRefreshed(ctx, fresh); err != nil { - slog.WarnContext(ctx, "failed to persist refreshed credentials", slog.Any("error", err)) + slog.WarnContext(ctx, "failed to persist refreshed credentials", slog.Any("error", sanitizeOAuthError(err))) } } @@ -511,7 +512,7 @@ func (p *DeviceFlowProvider) scheduleNextAutoRefresh( // Ensure credentials are fresh refreshFailed := false if _, err := p.GetToken(autoCtx); err != nil { - slog.WarnContext(autoCtx, "failed to auto refresh device flow token", slog.Any("error", err)) + slog.WarnContext(autoCtx, "failed to auto refresh device flow token", slog.Any("error", sanitizeOAuthError(err))) refreshFailed = true } @@ -669,3 +670,17 @@ func (p *DeviceFlowProvider) refresh(ctx context.Context, creds *OAuthCredential return refreshed, nil } + +// sanitizeOAuthError masks token-like values in error strings to prevent +// accidental propagation of secrets in logs. +func sanitizeOAuthError(err error) error { + if err == nil { + return nil + } + msg := err.Error() + msg = reTokenPattern.ReplaceAllString(msg, "***") + return errors.New(msg) +} + +// reTokenPattern matches common token patterns in error messages. +var reTokenPattern = regexp.MustCompile(`(?i)(bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9_\-\.]{10,}`) diff --git a/llm/oauth/token_provider.go b/llm/oauth/token_provider.go index 7ce9badbc..dc2aec2aa 100644 --- a/llm/oauth/token_provider.go +++ b/llm/oauth/token_provider.go @@ -21,7 +21,8 @@ func wrapHttpError(err error) error { } var httpErr *httpclient.Error if errors.As(err, &httpErr) && len(httpErr.Body) > 0 { - return fmt.Errorf("%w (response body: %s)", err, string(httpErr.Body)) + slog.Debug("oauth http error response body", "body", string(httpErr.Body)) + return fmt.Errorf("oauth http error: status %d", httpErr.StatusCode) } return err } @@ -38,14 +39,15 @@ type TokenGetter interface { // TokenProvider manages OAuth2 credentials for a transformer instance. // Each transformer has its own provider, so we can keep the credentials in memory. type TokenProvider struct { - httpClient *httpclient.HttpClient - oauthUrls OAuthUrls - strategy ExchangeStrategy - sf singleflight.Group - mu sync.RWMutex - creds *OAuthCredentials - userAgent string - onRefreshed func(ctx context.Context, refreshed *OAuthCredentials) error + httpClient *httpclient.HttpClient + oauthUrls OAuthUrls + strategy ExchangeStrategy + sf singleflight.Group + mu sync.RWMutex + creds *OAuthCredentials + userAgent string + onRefreshed func(ctx context.Context, refreshed *OAuthCredentials) error + allowedRedirectURIs map[string]struct{} autoMu sync.Mutex autoCancel context.CancelFunc @@ -63,6 +65,9 @@ type TokenProviderParams struct { // ExchangeStrategy defines how to format token requests (form-encoded or JSON) // If not provided, defaults to FormEncodedStrategy ExchangeStrategy ExchangeStrategy + // AllowedRedirectURIs is a server-side allowlist of valid redirect URIs. + // When non-nil, Exchange rejects requests with unrecognized redirect URIs. + AllowedRedirectURIs []string } type ExchangeParams struct { Code string @@ -83,13 +88,19 @@ func NewTokenProvider(params TokenProviderParams) *TokenProvider { strategy = &FormEncodedStrategy{UserAgent: params.UserAgent} } + allowedRedirectURIs := make(map[string]struct{}) + for _, uri := range params.AllowedRedirectURIs { + allowedRedirectURIs[uri] = struct{}{} + } + return &TokenProvider{ - httpClient: params.HTTPClient, - oauthUrls: params.OAuthUrls, - strategy: strategy, - userAgent: params.UserAgent, - creds: params.Credentials, - onRefreshed: params.OnRefreshed, + httpClient: params.HTTPClient, + oauthUrls: params.OAuthUrls, + strategy: strategy, + userAgent: params.UserAgent, + creds: params.Credentials, + onRefreshed: params.OnRefreshed, + allowedRedirectURIs: allowedRedirectURIs, } } @@ -119,6 +130,13 @@ func (p *TokenProvider) Exchange(ctx context.Context, params ExchangeParams) (*O return nil, errors.New("redirect_uri is empty") } + // Server-side redirect URI allowlist check + if len(p.allowedRedirectURIs) > 0 { + if _, ok := p.allowedRedirectURIs[params.RedirectURI]; !ok { + return nil, fmt.Errorf("redirect_uri %q is not in the allowed list", params.RedirectURI) + } + } + req, err := p.strategy.BuildExchangeRequest(params, p.oauthUrls.TokenUrl) if err != nil { return nil, fmt.Errorf("build exchange request: %w", err) diff --git a/llm/pipeline/pipeline.go b/llm/pipeline/pipeline.go index 3f25f2781..7d794f59a 100644 --- a/llm/pipeline/pipeline.go +++ b/llm/pipeline/pipeline.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "math/rand" "time" "github.com/looplj/axonhub/llm" @@ -258,6 +259,10 @@ func (p *pipeline) Process(ctx context.Context, request *httpclient.Request) (*R llmRequest.RawRequest = request + // Save original request body for retry safety (body may be consumed on first attempt) + savedBody := make([]byte, len(request.Body)) + copy(savedBody, request.Body) + var lastErr error channelSwitches := 0 @@ -265,6 +270,9 @@ func (p *pipeline) Process(ctx context.Context, request *httpclient.Request) (*R // Step 3: Process the request for { + // Restore body before each attempt + request.Body = savedBody + result, err := p.processRequest(ctx, llmRequest) if err == nil { return result, nil @@ -322,9 +330,21 @@ func (p *pipeline) Process(ctx context.Context, request *httpclient.Request) (*R break } - // Add retry delay if configured + // Add retry delay with exponential backoff and jitter, responsive to context cancellation. if p.retryDelay > 0 { - time.Sleep(p.retryDelay) + baseDelay := p.retryDelay + attemptDelay := baseDelay * time.Duration(1< 30*time.Second { + attemptDelay = 30 * time.Second + } + jitter := time.Duration(float64(attemptDelay) * (0.8 + 0.4*rand.Float64())) + delay := jitter + + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } } slog.WarnContext(ctx, "request process failed, retrying...", diff --git a/llm/streams/prepend.go b/llm/streams/prepend.go index c95e760eb..6354bd0e8 100644 --- a/llm/streams/prepend.go +++ b/llm/streams/prepend.go @@ -1,11 +1,19 @@ package streams +import "runtime" + func PrependStream[T any](stream Stream[T], items ...T) Stream[T] { - return &prependStream[T]{ + ps := &prependStream[T]{ stream: stream, prependItems: items, prependIndex: 0, } + runtime.SetFinalizer(ps, func(s *prependStream[T]) { + if !s.closed { + _ = s.stream.Close() + } + }) + return ps } type prependStream[T any] struct { @@ -13,6 +21,7 @@ type prependStream[T any] struct { prependItems []T prependIndex int current T + closed bool } func (s *prependStream[T]) Next() bool { @@ -30,4 +39,10 @@ func (s *prependStream[T]) Next() bool { func (s *prependStream[T]) Current() T { return s.current } func (s *prependStream[T]) Err() error { return s.stream.Err() } -func (s *prependStream[T]) Close() error { return s.stream.Close() } +func (s *prependStream[T]) Close() error { + if s.closed { + return nil + } + s.closed = true + return s.stream.Close() +} diff --git a/llm/transformer/aisdk/convert_request.go b/llm/transformer/aisdk/convert_request.go index 0314ccc53..40a001d3f 100644 --- a/llm/transformer/aisdk/convert_request.go +++ b/llm/transformer/aisdk/convert_request.go @@ -252,8 +252,9 @@ func convertToLLMRequestWithAPIFormat(req *Request, options *ConvertToLLMRequest } // Build assistant message content and tool calls var ( - contentParts []llm.MessageContentPart - toolCalls []llm.ToolCall + contentParts []llm.MessageContentPart + toolCalls []llm.ToolCall + reasoningContent string ) var toolMessages []llm.Message // tool role messages following assistant @@ -270,10 +271,9 @@ func convertToLLMRequestWithAPIFormat(req *Request, options *ConvertToLLMRequest contentParts = append(contentParts, *cp) } case p.Type == "reasoning" && p.Text != "": - // Map reasoning as a special content type - use text type for now - reasoningPart := llm.MessageContentPart{Type: "text", Text: lo.ToPtr(p.Text)} - // TODO: Add provider metadata support when LLM structs support it - contentParts = append(contentParts, reasoningPart) + // Preserve thinking semantics by setting ReasoningContent + // on the message instead of mapping as plain text. + reasoningContent = p.Text case p.Type == "dynamic-tool" || strings.HasPrefix(p.Type, "tool-"): // Only include non-streaming tool input states if p.State == "input-streaming" { @@ -335,8 +335,8 @@ func convertToLLMRequestWithAPIFormat(req *Request, options *ConvertToLLMRequest } } - // Append assistant message if it has content or tool calls - if len(contentParts) > 0 || len(toolCalls) > 0 { + // Append assistant message if it has content, tool calls, or reasoning + if len(contentParts) > 0 || len(toolCalls) > 0 || reasoningContent != "" { assistantMsg := llm.Message{ Role: "assistant", Content: llm.MessageContent{MultipleContent: contentParts}, @@ -348,6 +348,9 @@ func convertToLLMRequestWithAPIFormat(req *Request, options *ConvertToLLMRequest return toolCalls }(), } + if reasoningContent != "" { + assistantMsg.ReasoningContent = lo.ToPtr(reasoningContent) + } llmReq.Messages = append(llmReq.Messages, assistantMsg) } // Append tool messages after the assistant message diff --git a/llm/transformer/aisdk/convert_request_test.go b/llm/transformer/aisdk/convert_request_test.go index c235cdb1c..3a663ad18 100644 --- a/llm/transformer/aisdk/convert_request_test.go +++ b/llm/transformer/aisdk/convert_request_test.go @@ -268,15 +268,14 @@ func TestConvertToLLMRequestComprehensive_AssistantMessage(t *testing.T) { require.Len(t, result.Messages, 1) assert.Equal(t, "assistant", result.Messages[0].Role) - require.Len(t, result.Messages[0].Content.MultipleContent, 2) + require.Len(t, result.Messages[0].Content.MultipleContent, 1) - // Check reasoning part (mapped to text) - reasoningPart := result.Messages[0].Content.MultipleContent[0] - assert.Equal(t, "text", reasoningPart.Type) - assert.Equal(t, "Thinking...", *reasoningPart.Text) + // Reasoning is set on ReasoningContent field, not as a content part + require.NotNil(t, result.Messages[0].ReasoningContent) + assert.Equal(t, "Thinking...", *result.Messages[0].ReasoningContent) // Check text part - textPart := result.Messages[0].Content.MultipleContent[1] + textPart := result.Messages[0].Content.MultipleContent[0] assert.Equal(t, "text", textPart.Type) assert.Equal(t, "Hello, human!", *textPart.Text) }) diff --git a/llm/transformer/anthropic/aggregator.go b/llm/transformer/anthropic/aggregator.go index 7b4f729ef..4cbe98f3b 100644 --- a/llm/transformer/anthropic/aggregator.go +++ b/llm/transformer/anthropic/aggregator.go @@ -209,7 +209,7 @@ func AggregateStreamChunks(ctx context.Context, chunks []*httpclient.StreamEvent Type: "message", Role: "assistant", Content: contentBlocks, - Model: "claude-3-sonnet-20240229", + Model: "claude-sonnet-4-20250514", StopReason: stopReason, Usage: usage, } diff --git a/llm/transformer/anthropic/outbound_stream.go b/llm/transformer/anthropic/outbound_stream.go index d75b875a6..ee836f67d 100644 --- a/llm/transformer/anthropic/outbound_stream.go +++ b/llm/transformer/anthropic/outbound_stream.go @@ -190,8 +190,14 @@ func (s *outboundStream) transformStreamChunk(event *httpclient.StreamEvent) (*l } resp.Choices = []llm.Choice{choice} } else { - //nolint:nilnil // It is expected. - return nil, nil + // Non-tool_use content block: return empty response to maintain stream consistency + // and prevent index tracking issues in multi tool call scenarios. + resp.Choices = []llm.Choice{ + { + Index: 0, + Delta: &llm.Message{}, + }, + } } case "content_block_delta": diff --git a/llm/transformer/anthropic/testdata/llm-stop.stream.jsonl b/llm/transformer/anthropic/testdata/llm-stop.stream.jsonl index 3bbd8327d..dc7f65ae3 100644 --- a/llm/transformer/anthropic/testdata/llm-stop.stream.jsonl +++ b/llm/transformer/anthropic/testdata/llm-stop.stream.jsonl @@ -1,4 +1,5 @@ {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01Fbg5HKuVfmtT6mAMxQoCSn\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-3-7-sonnet-20250219\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":1,\"total_tokens\":22,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0}}}"} +{"LastEventID": "", "Type": "", "Data": "{\"id\": \"msg_bdrk_01Fbg5HKuVfmtT6mAMxQoCSn\", \"choices\": [{\"index\": 0, \"delta\": {}}], \"object\": \"chat.completion.chunk\", \"created\": 0, \"model\": \"claude-3-7-sonnet-20250219\"}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01Fbg5HKuVfmtT6mAMxQoCSn\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-3-7-sonnet-20250219\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"1 2 3 4 \"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01Fbg5HKuVfmtT6mAMxQoCSn\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-3-7-sonnet-20250219\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"5\\n6 7 8\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01Fbg5HKuVfmtT6mAMxQoCSn\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-3-7-sonnet-20250219\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" 9 10\\n11 12 \"}}]}"} diff --git a/llm/transformer/anthropic/testdata/llm-think.stream.jsonl b/llm/transformer/anthropic/testdata/llm-think.stream.jsonl index d0ae713a8..759ad2f78 100644 --- a/llm/transformer/anthropic/testdata/llm-think.stream.jsonl +++ b/llm/transformer/anthropic/testdata/llm-think.stream.jsonl @@ -1,4 +1,5 @@ {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}],\"usage\":{\"prompt_tokens\":587,\"completion_tokens\":1,\"total_tokens\":588,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0}}}"} +{"LastEventID": "", "Type": "", "Data": "{\"id\": \"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\", \"choices\": [{\"index\": 0, \"delta\": {}}], \"object\": \"chat.completion.chunk\", \"created\": 0, \"model\": \"claude-sonnet-4-20250514\"}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"The\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\" user\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\" is\"}}]}"} @@ -39,6 +40,7 @@ {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\" the coordinates an\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"d temperature unit.\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_signature\":\"EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds\"}}]}"} +{"LastEventID": "", "Type": "", "Data": "{\"id\": \"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\", \"choices\": [{\"index\": 0, \"delta\": {}}], \"object\": \"chat.completion.chunk\", \"created\": 0, \"model\": \"claude-sonnet-4-20250514\"}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"I'll\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" help\"}}]}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" you\"}}]}"} @@ -68,4 +70,4 @@ {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_bdrk_01E6Gr52e4i9TLwsDn8Sgimg\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\\\"}\"},\"index\":1}]}}],\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\"}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\"}"} {"LastEventID":"","Type":"","Data":"{\"id\":\"msg_bdrk_01DDaPSX8bJqM5dRkdv32TkC\",\"choices\":[],\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"usage\":{\"prompt_tokens\":587,\"completion_tokens\":226,\"total_tokens\":813,\"prompt_tokens_details\":{},\"completion_tokens_details\":{}}}"} -{"LastEventID":"","Type":"","Data":"{\"id\":\"\",\"choices\":null,\"object\":\"[DONE]\",\"created\":0,\"model\":\"\"}"} \ No newline at end of file +{"LastEventID":"","Type":"","Data":"{\"id\":\"\",\"choices\":null,\"object\":\"[DONE]\",\"created\":0,\"model\":\"\"}"} diff --git a/llm/transformer/anthropic/testdata/llm-tool.stream.jsonl b/llm/transformer/anthropic/testdata/llm-tool.stream.jsonl index 2fa940900..081143e1a 100644 --- a/llm/transformer/anthropic/testdata/llm-tool.stream.jsonl +++ b/llm/transformer/anthropic/testdata/llm-tool.stream.jsonl @@ -1,4 +1,5 @@ {"LastEventID":"","Type":"data","Data":"{\"id\":\"msg_bdrk_01UJYyE5HVJvdHL9cSvFeFg2\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}],\"usage\":{\"prompt_tokens\":558,\"completion_tokens\":1,\"total_tokens\":559,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0}}}"} +{"LastEventID": "", "Type": "", "Data": "{\"id\": \"msg_bdrk_01UJYyE5HVJvdHL9cSvFeFg2\", \"choices\": [{\"index\": 0, \"delta\": {}}], \"object\": \"chat.completion.chunk\", \"created\": 0, \"model\": \"claude-sonnet-4-20250514\"}"} {"LastEventID":"","Type":"data","Data":"{\"id\":\"msg_bdrk_01UJYyE5HVJvdHL9cSvFeFg2\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"I\"}}]}"} {"LastEventID":"","Type":"data","Data":"{\"id\":\"msg_bdrk_01UJYyE5HVJvdHL9cSvFeFg2\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"'ll\"}}]}"} {"LastEventID":"","Type":"data","Data":"{\"id\":\"msg_bdrk_01UJYyE5HVJvdHL9cSvFeFg2\",\"object\":\"chat.completion.chunk\",\"created\":0,\"model\":\"claude-sonnet-4-20250514\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" help\"}}]}"} diff --git a/llm/transformer/anthropic/usage.go b/llm/transformer/anthropic/usage.go index b569150bf..e3f379333 100644 --- a/llm/transformer/anthropic/usage.go +++ b/llm/transformer/anthropic/usage.go @@ -42,8 +42,10 @@ func convertToLlmUsage(usage *Usage, platformType PlatformType) *llm.Usage { return nil } - // Handle moonshot's cached_tokens field - if usage.CachedTokens > 0 && usage.CacheCreationInputTokens == 0 { + // Handle Moonshot cached_tokens field (Moonshot uses "cached_tokens" instead of + // "cache_read_input_tokens"). Guard by platformType to avoid false positives + // on other providers that happen to return similar field patterns. + if platformType == PlatformMoonshot && usage.CachedTokens > 0 { usage.CacheReadInputTokens = usage.CachedTokens } @@ -102,7 +104,7 @@ func convertToAnthropicUsage(llmUsage *llm.Usage) *Usage { Ephemeral5mInputTokens: llmUsage.PromptTokensDetails.WriteCached5MinTokens, Ephemeral1hInputTokens: llmUsage.PromptTokensDetails.WriteCached1HourTokens, } - usage.InputTokens -= (usage.CacheReadInputTokens + usage.CacheCreationInputTokens) + usage.InputTokens = max(0, usage.InputTokens - usage.CacheReadInputTokens - usage.CacheCreationInputTokens) } // Note: Anthropic doesn't have a direct equivalent for reasoning tokens in their current API diff --git a/llm/transformer/anthropic/usage_test.go b/llm/transformer/anthropic/usage_test.go index c5ddc1088..132779e3e 100644 --- a/llm/transformer/anthropic/usage_test.go +++ b/llm/transformer/anthropic/usage_test.go @@ -91,7 +91,7 @@ func Test_convertUsage(t *testing.T) { }, }, { - name: "moonshot cached tokens conversion - Anthropic official", + name: "cached tokens field present but platform is not moonshot", args: args{ usage: &Usage{ InputTokens: 100, @@ -104,12 +104,9 @@ func Test_convertUsage(t *testing.T) { platformType: PlatformDirect, }, want: &llm.Usage{ - PromptTokens: 175, // 100 + 75 + PromptTokens: 100, // CachedTokens ignored for non-Moonshot platforms CompletionTokens: 50, - TotalTokens: 225, // 175 + 50 - PromptTokensDetails: &llm.PromptTokensDetails{ - CachedTokens: 75, - }, + TotalTokens: 150, // 100 + 50 }, }, { diff --git a/llm/transformer/bailian/outbound.go b/llm/transformer/bailian/outbound.go index 023fe7a0f..8e767bf3e 100644 --- a/llm/transformer/bailian/outbound.go +++ b/llm/transformer/bailian/outbound.go @@ -3,6 +3,7 @@ package bailian import ( "context" "fmt" + "encoding/json" "strings" "github.com/looplj/axonhub/llm" @@ -60,10 +61,38 @@ func NewOutboundTransformerWithConfig(config *Config) (transformer.Outbound, err // TransformRequest applies Bailian-specific request normalization before delegating to OpenAI-compatible transformer. func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm.Request) (*httpclient.Request, error) { llmReq = mergeConsecutiveToolCallMessages(llmReq) + llmReq = sanitizeForBailian(llmReq) return t.Outbound.TransformRequest(ctx, llmReq) } +// sanitizeForBailian removes unsupported fields and fixes format issues for Bailian API compatibility. +func sanitizeForBailian(req *llm.Request) *llm.Request { + if req == nil { + return nil + } + + if req.ResponseFormat != nil { + req.ResponseFormat = nil + } + + for i := range req.Messages { + for j := range req.Messages[i].ToolCalls { + args := req.Messages[i].ToolCalls[j].Function.Arguments + if args == "" { + req.Messages[i].ToolCalls[j].Function.Arguments = "{}" + continue + } + if !json.Valid([]byte(args)) { + wrapped, _ := json.Marshal(args) + req.Messages[i].ToolCalls[j].Function.Arguments = string(wrapped) + } + } + } + + return req +} + func mergeConsecutiveToolCallMessages(req *llm.Request) *llm.Request { if req == nil || len(req.Messages) < 2 { return req diff --git a/llm/transformer/bailian/sanitize_test.go b/llm/transformer/bailian/sanitize_test.go new file mode 100644 index 000000000..6e582ceff --- /dev/null +++ b/llm/transformer/bailian/sanitize_test.go @@ -0,0 +1,106 @@ +package bailian + +import ( + "encoding/json" + "testing" + + "github.com/looplj/axonhub/llm" +) + +func TestSanitizeForBailian_StripResponseFormat(t *testing.T) { + req := &llm.Request{ + ResponseFormat: &llm.ResponseFormat{ + Type: "json_object", + }, + Messages: []llm.Message{}, + } + + result := sanitizeForBailian(req) + + if result.ResponseFormat != nil { + t.Errorf("expected ResponseFormat to be nil, got %+v", result.ResponseFormat) + } +} + +func TestSanitizeForBailian_EmptyArguments(t *testing.T) { + args := "" + req := &llm.Request{ + Messages: []llm.Message{ + { + Role: "assistant", + ToolCalls: []llm.ToolCall{ + { + Function: llm.FunctionCall{ + Name: "test_func", + Arguments: args, + }, + }, + }, + }, + }, + } + + result := sanitizeForBailian(req) + + expected := "{}" + if result.Messages[0].ToolCalls[0].Function.Arguments != expected { + t.Errorf("expected empty arguments to become %q, got %q", expected, result.Messages[0].ToolCalls[0].Function.Arguments) + } +} + +func TestSanitizeForBailian_NonJSONArguments(t *testing.T) { + req := &llm.Request{ + Messages: []llm.Message{ + { + Role: "assistant", + ToolCalls: []llm.ToolCall{ + { + Function: llm.FunctionCall{ + Name: "test_func", + Arguments: "not json at all", + }, + }, + }, + }, + }, + } + + result := sanitizeForBailian(req) + + args := result.Messages[0].ToolCalls[0].Function.Arguments + if !json.Valid([]byte(args)) { + t.Errorf("expected arguments to be valid JSON, got %q", args) + } +} + +func TestSanitizeForBailian_ValidJSONArgumentsUnchanged(t *testing.T) { + original := `{"key": "value"}` + req := &llm.Request{ + Messages: []llm.Message{ + { + Role: "assistant", + ToolCalls: []llm.ToolCall{ + { + Function: llm.FunctionCall{ + Name: "test_func", + Arguments: original, + }, + }, + }, + }, + }, + } + + result := sanitizeForBailian(req) + + if result.Messages[0].ToolCalls[0].Function.Arguments != original { + t.Errorf("expected valid JSON arguments to be unchanged, got %q", result.Messages[0].ToolCalls[0].Function.Arguments) + } +} + +func TestSanitizeForBailian_NilRequest(t *testing.T) { + result := sanitizeForBailian(nil) + if result != nil { + t.Errorf("expected nil result for nil request") + } +} diff --git a/llm/transformer/gemini/convert.go b/llm/transformer/gemini/convert.go index fb9e8209b..ead1982ab 100644 --- a/llm/transformer/gemini/convert.go +++ b/llm/transformer/gemini/convert.go @@ -507,7 +507,7 @@ func convertToGeminiUsage(chatUsage *llm.Usage) *UsageMetadata { if chatUsage.CompletionTokensDetails != nil { usage.ThoughtsTokenCount = chatUsage.CompletionTokensDetails.ReasoningTokens - usage.CandidatesTokenCount = chatUsage.CompletionTokens - usage.ThoughtsTokenCount + usage.CandidatesTokenCount = max(0, chatUsage.CompletionTokens - usage.ThoughtsTokenCount) } if len(chatUsage.CompletionModalityTokenDetails) > 0 { diff --git a/llm/transformer/gemini/inbound.go b/llm/transformer/gemini/inbound.go index 1f594d208..5a7ac06e2 100644 --- a/llm/transformer/gemini/inbound.go +++ b/llm/transformer/gemini/inbound.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strings" "github.com/looplj/axonhub/llm" @@ -27,26 +28,38 @@ func NewInboundTransformer() *InboundTransformer { // extractRequestParams extracts the model and stream flag from the request URL. func extractRequestParams(httpReq *httpclient.Request) (string, bool, error) { - urlParts := strings.Split(httpReq.Path, "/") - if len(urlParts) < 1 { + // Use url.Parse for robust handling of non-standard endpoints + var rawPath string + if u, err := url.Parse(httpReq.Path); err == nil { + rawPath = u.Path + } else { + rawPath = httpReq.Path + } + + // Trim trailing slash and extract the last path segment + rawPath = strings.TrimRight(rawPath, "/") + segments := strings.Split(rawPath, "/") + if len(segments) == 0 || segments[len(segments)-1] == "" { return "", false, fmt.Errorf("%w: invalid request path: %s", ErrInvalidRequestURL, httpReq.Path) } - suffix := urlParts[len(urlParts)-1] + last := segments[len(segments)-1] - suffixParts := strings.Split(suffix, ":") - if len(suffixParts) < 2 { - return "", false, fmt.Errorf("%w: invalid request path: %s", ErrInvalidRequestURL, httpReq.Path) + // Check for colon-separated action suffix (e.g., model-name:generateContent) + if idx := strings.LastIndex(last, ":"); idx >= 0 { + model := last[:idx] + action := last[idx+1:] + switch action { + case "generateContent": + return model, false, nil + case "streamGenerateContent": + return model, true, nil + } } - switch suffixParts[1] { - case "generateContent": - return suffixParts[0], false, nil - case "streamGenerateContent": - return suffixParts[0], true, nil - default: - return "", false, fmt.Errorf("%w: invalid request path: %s", ErrInvalidRequestURL, httpReq.Path) - } + // Fallback: treat the entire last segment as the model name, + // defaulting to non-streaming for non-standard endpoints. + return last, false, nil } // TransformRequest transforms Gemini HTTP request to unified Request format. diff --git a/llm/transformer/gemini/outbound.go b/llm/transformer/gemini/outbound.go index fa477b741..bf70ecfca 100644 --- a/llm/transformer/gemini/outbound.go +++ b/llm/transformer/gemini/outbound.go @@ -60,7 +60,7 @@ func NewOutboundTransformer(baseURL, apiKey string) (transformer.Outbound, error return NewOutboundTransformerWithConfig(config) } -func clenupConfig(config Config) Config { +func cleanupConfig(config Config) Config { if config.BaseURL == "" { config.BaseURL = strings.TrimSuffix(DefaultBaseURL, "/") } @@ -86,7 +86,7 @@ func clenupConfig(config Config) Config { // NewOutboundTransformerWithConfig creates a new Gemini OutboundTransformer with unified configuration. func NewOutboundTransformerWithConfig(config Config) (transformer.Outbound, error) { - config = clenupConfig(config) + config = cleanupConfig(config) return &OutboundTransformer{ config: config, @@ -213,7 +213,7 @@ func (t *OutboundTransformer) buildFullRequestURL(llmReq *llm.Request) string { // If base URL starts with Cloudflare gateway, don't add /v1 prefix if t.config.PlatformType == PlatformVertex { baseURL := strings.TrimSuffix(t.config.BaseURL, "/") - if strings.Contains(baseURL, "/v1/") { + if strings.Contains(baseURL, "/v1/") || strings.HasSuffix(baseURL, "/v1") { return fmt.Sprintf("%s/publishers/google/models/%s:%s", baseURL, llmReq.Model, action) } diff --git a/llm/transformer/gemini/outbound_test.go b/llm/transformer/gemini/outbound_test.go index 6692bf47f..318fc6139 100644 --- a/llm/transformer/gemini/outbound_test.go +++ b/llm/transformer/gemini/outbound_test.go @@ -14,7 +14,7 @@ import ( "github.com/looplj/axonhub/llm/transformer/shared" ) -func TestClenupConfig(t *testing.T) { +func TestCleanupConfig(t *testing.T) { tests := []struct { name string input Config @@ -104,7 +104,7 @@ func TestClenupConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := clenupConfig(tt.input) + result := cleanupConfig(tt.input) require.Equal(t, tt.expected, result) }) } diff --git a/llm/transformer/openai/aggregator.go b/llm/transformer/openai/aggregator.go index 31582f100..047c0d1fe 100644 --- a/llm/transformer/openai/aggregator.go +++ b/llm/transformer/openai/aggregator.go @@ -259,7 +259,11 @@ func AggregateStreamChunks(ctx context.Context, chunks []*httpclient.StreamEvent Created: lastChunkResponse.Created, SystemFingerprint: systemFingerprint, Choices: choices, - Usage: usage.ToLLMUsage(), + } + + // Handle nil usage by providing zero-value + if usage != nil { + response.Usage = usage.ToLLMUsage() } // Add citations to response if any were collected @@ -281,8 +285,14 @@ func AggregateStreamChunks(ctx context.Context, chunks []*httpclient.StreamEvent return nil, llm.ResponseMeta{}, err } - return data, llm.ResponseMeta{ - ID: response.ID, - Usage: usage.ToLLMUsage(), - }, nil + meta := llm.ResponseMeta{ + ID: response.ID, + } + + // Handle nil usage by providing zero-value + if usage != nil { + meta.Usage = usage.ToLLMUsage() + } + + return data, meta, nil } diff --git a/llm/transformer/openai/inbound.go b/llm/transformer/openai/inbound.go index 6112fddf3..5ef3fc275 100644 --- a/llm/transformer/openai/inbound.go +++ b/llm/transformer/openai/inbound.go @@ -38,9 +38,6 @@ func (t *InboundTransformer) TransformRequest( // Check content type contentType := httpReq.Headers.Get("Content-Type") - if contentType == "" { - contentType = httpReq.Headers.Get("Content-Type") - } if !strings.Contains(strings.ToLower(contentType), "application/json") { return nil, fmt.Errorf("%w: unsupported content type: %s", transformer.ErrInvalidRequest, contentType) diff --git a/llm/transformer/openai/inbound_convert.go b/llm/transformer/openai/inbound_convert.go index 0928f9ec4..f1cde4cc7 100644 --- a/llm/transformer/openai/inbound_convert.go +++ b/llm/transformer/openai/inbound_convert.go @@ -148,17 +148,20 @@ func (m Message) ToLLMMessage() llm.Message { // Convert ToolCalls if m.ToolCalls != nil { - msg.ToolCalls = lo.Map(m.ToolCalls, func(tc ToolCall, _ int) llm.ToolCall { - return tc.ToLLMToolCall() - }) - - firstThoughtSignature := lo.FindOrElse(msg.ToolCalls, llm.ToolCall{}, func(tc llm.ToolCall) bool { - raw, ok := tc.TransformerMetadata[TransformerMetadataKeyGoogleThoughtSignature].(string) - return ok && raw != "" - }) - - if raw, ok := firstThoughtSignature.TransformerMetadata[TransformerMetadataKeyGoogleThoughtSignature].(string); ok { - msg.ReasoningSignature = lo.ToPtr(raw) + msg.ToolCalls = make([]llm.ToolCall, len(m.ToolCalls)) + for i, tc := range m.ToolCalls { + msg.ToolCalls[i] = tc.ToLLMToolCall() + + // Extract ReasoningSignature once from the first tool call that carries it. + if msg.ReasoningSignature == nil { + extraContent := tc.ExtraContent + if extraContent == nil && tc.ExtraFields != nil { + extraContent = tc.ExtraFields.ExtraContent + } + if extraContent != nil && extraContent.Google != nil && extraContent.Google.ThoughtSignature != "" { + msg.ReasoningSignature = lo.ToPtr(extraContent.Google.ThoughtSignature) + } + } } } diff --git a/llm/transformer/openai/model.go b/llm/transformer/openai/model.go index 76d66eae6..884c3277c 100644 --- a/llm/transformer/openai/model.go +++ b/llm/transformer/openai/model.go @@ -357,10 +357,11 @@ func (t Tool) ToLLMTool() llm.Tool { // Function represents a function definition. type Function struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters json.RawMessage `json:"parameters"` - Strict *bool `json:"strict,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters"` + ParametersJsonSchema json.RawMessage `json:"parameters_json_schema,omitempty"` + Strict *bool `json:"strict,omitempty"` } // FunctionCall represents a function call. diff --git a/llm/transformer/openai/outbound_convert.go b/llm/transformer/openai/outbound_convert.go index 549c0aba0..2cf86417f 100644 --- a/llm/transformer/openai/outbound_convert.go +++ b/llm/transformer/openai/outbound_convert.go @@ -31,6 +31,8 @@ func RequestFromLLM(r *llm.Request) *Request { Metadata: r.Metadata, Modalities: r.Modalities, ReasoningEffort: r.ReasoningEffort, + ReasoningBudget: r.ReasoningBudget, + ReasoningSummary: r.ReasoningSummary, ServiceTier: r.ServiceTier, Stream: r.Stream, ParallelToolCalls: r.ParallelToolCalls, @@ -223,10 +225,11 @@ func ToolFromLLM(t llm.Tool) Tool { return Tool{ Type: t.Type, Function: Function{ - Name: t.Function.Name, - Description: t.Function.Description, - Parameters: t.Function.Parameters, - Strict: t.Function.Strict, + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + ParametersJsonSchema: t.Function.ParametersJsonSchema, + Strict: t.Function.Strict, }, } } diff --git a/llm/transformer/openai/responses/aggregator.go b/llm/transformer/openai/responses/aggregator.go index f03a6a54b..3904f07ee 100644 --- a/llm/transformer/openai/responses/aggregator.go +++ b/llm/transformer/openai/responses/aggregator.go @@ -511,7 +511,7 @@ func (a *streamAggregator) buildResponse() *Response { Status: lo.ToPtr(item.Status), CallID: item.CallID, Name: item.Name, - Arguments: item.Arguments.String(), + Arguments: maybeSanitizeSpawnAgentArgs(item.Name, item.Arguments.String()), }) case "custom_tool_call": diff --git a/llm/transformer/openai/responses/outbound_convert.go b/llm/transformer/openai/responses/outbound_convert.go index ca4dfe682..2ce0000c4 100644 --- a/llm/transformer/openai/responses/outbound_convert.go +++ b/llm/transformer/openai/responses/outbound_convert.go @@ -536,7 +536,7 @@ func convertOutputToMessage(output []Item, scope shared.TransportScope, transfor Type: "function", Function: llm.FunctionCall{ Name: outputItem.Name, - Arguments: outputItem.Arguments, + Arguments: maybeSanitizeSpawnAgentArgs(outputItem.Name, outputItem.Arguments), }, }) case "custom_tool_call": diff --git a/llm/transformer/openai/responses/spawn_agent_sanitizer.go b/llm/transformer/openai/responses/spawn_agent_sanitizer.go new file mode 100644 index 000000000..bb6f676f9 --- /dev/null +++ b/llm/transformer/openai/responses/spawn_agent_sanitizer.go @@ -0,0 +1,58 @@ +package responses + +import ( + "encoding/json" + "os" +) + +// maybeSanitizeSpawnAgentArgs checks if AXONHUB_SANITIZE_SPAWN_AGENT_ARGS=1 +// and if toolName is "spawn_agent", removes empty "items" field from arguments. +func maybeSanitizeSpawnAgentArgs(toolName string, arguments string) string { + if os.Getenv("AXONHUB_SANITIZE_SPAWN_AGENT_ARGS") != "1" { + return arguments + } + if toolName != "spawn_agent" { + return arguments + } + return sanitizeSpawnAgentArguments(arguments) +} + +// sanitizeSpawnAgentArguments removes the "items" field if it's an empty array +// and "message" is present and non-empty. +func sanitizeSpawnAgentArguments(arguments string) string { + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(arguments), &raw); err != nil { + return arguments + } + + // message must exist and be non-empty + msgRaw, ok := raw["message"] + if !ok { + return arguments + } + var msgStr string + if err := json.Unmarshal(msgRaw, &msgStr); err != nil || msgStr == "" { + return arguments + } + + // items must exist and be empty array + itemsRaw, ok := raw["items"] + if !ok { + return arguments // no items field, nothing to do + } + var items []json.RawMessage + if err := json.Unmarshal(itemsRaw, &items); err != nil { + return arguments + } + if len(items) != 0 { + return arguments // items has content, don't touch + } + + // Remove items and re-serialize + delete(raw, "items") + result, err := json.Marshal(raw) + if err != nil { + return arguments + } + return string(result) +} diff --git a/llm/transformer/openai/responses/spawn_agent_sanitizer_test.go b/llm/transformer/openai/responses/spawn_agent_sanitizer_test.go new file mode 100644 index 000000000..76c1be7bf --- /dev/null +++ b/llm/transformer/openai/responses/spawn_agent_sanitizer_test.go @@ -0,0 +1,158 @@ +package responses + +import ( + "os" + "testing" +) + +func TestSanitizeSpawnAgentArgs(t *testing.T) { + tests := []struct { + name string + toolName string + arguments string + envEnabled bool + want string + }{ + { + name: "message only - unchanged", + toolName: "spawn_agent", + arguments: `{"message":"task"}`, + envEnabled: true, + want: `{"message":"task"}`, + }, + { + name: "items with content - unchanged", + toolName: "spawn_agent", + arguments: `{"items":[{"message":"task1"}]}`, + envEnabled: true, + want: `{"items":[{"message":"task1"}]}`, + }, + { + name: "message with empty items - items removed", + toolName: "spawn_agent", + arguments: `{"message":"task","items":[]}`, + envEnabled: true, + want: `{"message":"task"}`, + }, + { + name: "message with non-empty items - unchanged", + toolName: "spawn_agent", + arguments: `{"message":"task","items":[{"message":"task1"}]}`, + envEnabled: true, + want: `{"message":"task","items":[{"message":"task1"}]}`, + }, + { + name: "items empty without message - unchanged", + toolName: "spawn_agent", + arguments: `{"items":[]}`, + envEnabled: true, + want: `{"items":[]}`, + }, + { + name: "invalid json - unchanged", + toolName: "spawn_agent", + arguments: `{not valid json}`, + envEnabled: true, + want: `{not valid json}`, + }, + { + name: "toolName not spawn_agent - unchanged", + toolName: "other_tool", + arguments: `{"message":"task","items":[]}`, + envEnabled: true, + want: `{"message":"task","items":[]}`, + }, + { + name: "env not set - unchanged", + toolName: "spawn_agent", + arguments: `{"message":"task","items":[]}`, + envEnabled: false, + want: `{"message":"task","items":[]}`, + }, + { + name: "message empty string - unchanged", + toolName: "spawn_agent", + arguments: `{"message":"","items":[]}`, + envEnabled: true, + want: `{"message":"","items":[]}`, + }, + { + name: "items is not array - unchanged", + toolName: "spawn_agent", + arguments: `{"message":"task","items":"not an array"}`, + envEnabled: true, + want: `{"message":"task","items":"not an array"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envEnabled { + os.Setenv("AXONHUB_SANITIZE_SPAWN_AGENT_ARGS", "1") + } else { + os.Unsetenv("AXONHUB_SANITIZE_SPAWN_AGENT_ARGS") + } + t.Cleanup(func() { + os.Unsetenv("AXONHUB_SANITIZE_SPAWN_AGENT_ARGS") + }) + + got := maybeSanitizeSpawnAgentArgs(tt.toolName, tt.arguments) + if got != tt.want { + t.Errorf("maybeSanitizeSpawnAgentArgs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSanitizeSpawnAgentArguments(t *testing.T) { + tests := []struct { + name string + arguments string + want string + }{ + { + name: "message only - unchanged", + arguments: `{"message":"task"}`, + want: `{"message":"task"}`, + }, + { + name: "message with empty items - items removed", + arguments: `{"message":"task","items":[]}`, + want: `{"message":"task"}`, + }, + { + name: "message with non-empty items - unchanged", + arguments: `{"message":"task","items":[{"type":"text"}]}`, + want: `{"message":"task","items":[{"type":"text"}]}`, + }, + { + name: "no message field - unchanged", + arguments: `{"items":[],"other":"value"}`, + want: `{"items":[],"other":"value"}`, + }, + { + name: "empty message - unchanged", + arguments: `{"message":"","items":[]}`, + want: `{"message":"","items":[]}`, + }, + { + name: "invalid json - unchanged", + arguments: `{invalid}`, + want: `{invalid}`, + }, + { + name: "items is object not array - unchanged", + arguments: `{"message":"task","items":{}}`, + want: `{"message":"task","items":{}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeSpawnAgentArguments(tt.arguments) + if got != tt.want { + t.Errorf("sanitizeSpawnAgentArguments() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/llm/transformer/openai/responses/usage.go b/llm/transformer/openai/responses/usage.go index 923b5a9f7..f28f00f65 100644 --- a/llm/transformer/openai/responses/usage.go +++ b/llm/transformer/openai/responses/usage.go @@ -17,6 +17,9 @@ type Usage struct { } func (u *Usage) ToUsage() *llm.Usage { + if u == nil { + return nil + } return &llm.Usage{ PromptTokens: u.InputTokens, CompletionTokens: u.OutputTokens,