diff --git a/PERMISSION_UI_PLAN.md b/PERMISSION_UI_PLAN.md new file mode 100644 index 000000000..fb879bf7f --- /dev/null +++ b/PERMISSION_UI_PLAN.md @@ -0,0 +1,46 @@ +# Permission And UI Plan + +## Goals + +- Project roles grant usable project access without requiring system scopes. +- The server publishes effective project scopes so API authorization, routes, menus, and controls share one source of truth. +- Read permission shows information; write permission alone exposes mutation controls. +- Project members select from project-approved, enabled models without viewing global Channel configuration. + +## Role Presets + +New projects create these project roles. Invitations bind to a concrete project role when created. + +| Role | Scope set | Intended use | +| --- | --- | --- | +| Project Admin | `read/write_users`, `read/write_roles`, `read/write_api_keys`, `read/write_prompts`, `read/write_requests` | Manage the project, its members, and its resources. | +| Developer | `read/write_api_keys`, `read/write_prompts`, `write_requests` | Use the project, manage API keys and prompts, and use Playground. Personal request visibility is not treated as project-wide request access. | +| Viewer | `read_prompts`, `read_requests` | Inspect permitted project data without changing it. | + +System Channels, Models, Data Storage, Settings, and global analytics remain system-level resources. A project role never receives `read_channels` or `write_channels` merely to use Playground. + +## Authorization Contract + +1. `UserProjectInfo.effectiveScopes` is the union of direct membership scopes and roles assigned in that project. +2. Frontend navigation, route guards, and mutation affordances use `effectiveScopes` for the selected project. +3. The server continues to authorize every query and mutation. A hidden control is never an authorization boundary. +4. Invitation creation validates that the selected role belongs to the invitation project and that the inviter may grant its scopes. Registration creates membership and role assignment in one transaction. + +## UI Contract + +| Capability | UI behavior | +| --- | --- | +| Read resource | Show route, list, detail values, and static state. | +| Write resource | Show create, edit, delete, bulk actions, switches, inline editors, and ordering controls. | +| Read-only Channel/Model/Prompt | Render status as a non-interactive badge. Hide mutation columns and controls. | +| Project Playground | Query project-profile-approved enabled models only. Disabled or excluded Channels are never listed. | + +The system Channel list remains an audit/configuration view: a system user with `read_channels` can see disabled Channels as static rows. The project model picker is a separate availability view and excludes them at the API boundary. + +## Delivery Order + +1. Add effective scopes and correct default project roles. +2. Bind invitations to a validated project role. +3. Switch frontend permission consumers to effective scopes and align the Playground navigation guard. +4. Gate read-only controls in Channel, Model, Prompt, and API Key surfaces. +5. Add focused tests for role assignment, effective scopes, invitation registration, and read-only UI behavior. diff --git a/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx b/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx index 6948ec32d..205d626d5 100644 --- a/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx +++ b/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTranslation } from 'react-i18next'; @@ -12,11 +12,14 @@ import { useApiKeysContext } from '../context/apikeys-context'; import { useCreateApiKey } from '../data/apikeys'; import { CreateApiKeyInput, createApiKeyInputSchema } from '../data/schema'; import { ScopesSelect } from '@/components/scopes-select'; +import { usePermissions } from '@/hooks/usePermissions'; export function ApiKeysCreateDialog() { const { t } = useTranslation(); const { isDialogOpen, closeDialog, openDialog, setSelectedApiKey } = useApiKeysContext(); const createApiKey = useCreateApiKey(); + const { hasProjectScope } = usePermissions(); + const canCreateProjectApiKey = hasProjectScope('write_users') && hasProjectScope('write_roles'); const [isSubmitting, setIsSubmitting] = useState(false); const [ipRestrictionEnabled, setIPRestrictionEnabled] = useState(false); const [ipInput, setIpInput] = useState(''); @@ -27,7 +30,7 @@ export function ApiKeysCreateDialog() { resolver: zodResolver(createApiKeyInputSchema), defaultValues: { name: '', - type: 'user', + type: 'personal', scopes: undefined, allowedIps: [], }, @@ -35,6 +38,12 @@ export function ApiKeysCreateDialog() { const apiKeyType = form.watch('type'); + useEffect(() => { + if (!canCreateProjectApiKey && form.getValues('type') === 'user') { + form.setValue('type', 'personal'); + } + }, [canCreateProjectApiKey, form]); + const onSubmit = async (data: CreateApiKeyInput) => { setIsSubmitting(true); try { @@ -100,12 +109,14 @@ export function ApiKeysCreateDialog() { {t('apikeys.dialogs.fields.type.label')} - - - - - {t('apikeys.dialogs.fields.type.user')} - + {canCreateProjectApiKey && ( + + + + + {t('apikeys.dialogs.fields.type.user')} + + )} diff --git a/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx b/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx index 8b007c9d8..696c23f1d 100644 --- a/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx +++ b/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx @@ -1,6 +1,7 @@ import { IconPlus, IconTemplate } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { PermissionGuard } from '@/components/permission-guard'; import { useApiKeysContext } from '../context/apikeys-context'; export function ApiKeysPrimaryButtons() { @@ -9,14 +10,16 @@ export function ApiKeysPrimaryButtons() { return (
- - + + + +
); } diff --git a/frontend/src/features/apikeys/data/apikeys.ts b/frontend/src/features/apikeys/data/apikeys.ts index 10ae030ae..8f6d9afe6 100644 --- a/frontend/src/features/apikeys/data/apikeys.ts +++ b/frontend/src/features/apikeys/data/apikeys.ts @@ -313,7 +313,6 @@ const APIKEY_PROFILE_TEMPLATES_QUERY = ` } } } - project { id name } } } totalCount diff --git a/frontend/src/features/channels/components/channels-columns.tsx b/frontend/src/features/channels/components/channels-columns.tsx index b5df0c2a7..6a529e982 100644 --- a/frontend/src/features/channels/components/channels-columns.tsx +++ b/frontend/src/features/channels/components/channels-columns.tsx @@ -60,6 +60,7 @@ const clampWeight = (value: number) => formatWeight(Math.min(MAX_WEIGHT, Math.ma const StatusSwitchCell = memo(({ row }: { row: Row }) => { const channel = row.original; const [dialogOpen, setDialogOpen] = useState(false); + const { channelPermissions } = usePermissions(); const isEnabled = channel.status === 'enabled'; const isArchived = channel.status === 'archived'; @@ -70,6 +71,10 @@ const StatusSwitchCell = memo(({ row }: { row: Row }) => { } }, [isArchived]); + if (!channelPermissions.canWrite) { + return {channel.status}; + } + return (
@@ -526,6 +531,7 @@ const OrderingWeightCell = memo(({ row }: { row: Row }) => { const [isEditing, setIsEditing] = useState(false); const [weight, setWeight] = useState(initialWeight?.toString() || '1'); const updateChannel = useUpdateChannel(); + const { channelPermissions } = usePermissions(); const inputRef = useRef(null); useEffect(() => { @@ -570,6 +576,10 @@ const OrderingWeightCell = memo(({ row }: { row: Row }) => { [handleSave, initialWeight] ); + if (!channelPermissions.canWrite) { + return {initialWeight ?? '-'}; + } + if (isEditing) { return (
diff --git a/frontend/src/features/models/components/models-columns.tsx b/frontend/src/features/models/components/models-columns.tsx index eae454c85..7a9a56df5 100644 --- a/frontend/src/features/models/components/models-columns.tsx +++ b/frontend/src/features/models/components/models-columns.tsx @@ -21,6 +21,7 @@ import { useDeveloperLabel } from './models-table'; function StatusSwitchCell({ row }: { row: Row }) { const model = row.original; const [dialogOpen, setDialogOpen] = useState(false); + const { channelPermissions } = usePermissions(); const isEnabled = model.status === 'enabled'; const isArchived = model.status === 'archived'; @@ -31,6 +32,10 @@ function StatusSwitchCell({ row }: { row: Row }) { } }, [isArchived]); + if (!channelPermissions.canWrite) { + return {model.status}; + } + return ( <> @@ -328,15 +333,15 @@ export const createColumns = (t: ReturnType['t'], canWrit enableSorting: true, enableHiding: false, }, - { - id: 'actions', - header: t('common.columns.actions'), - cell: DataTableRowActions, - meta: { - className: 'w-[88px] min-w-[88px] pr-3 pl-0', - }, - enableSorting: false, - enableHiding: false, - }, + ...(canWrite + ? [{ + id: 'actions', + header: t('common.columns.actions'), + cell: DataTableRowActions, + meta: { className: 'w-[88px] min-w-[88px] pr-3 pl-0' }, + enableSorting: false, + enableHiding: false, + }] + : []), ]; }; diff --git a/frontend/src/features/playground/index.tsx b/frontend/src/features/playground/index.tsx index 70439d43d..2d327417d 100644 --- a/frontend/src/features/playground/index.tsx +++ b/frontend/src/features/playground/index.tsx @@ -22,8 +22,10 @@ import { PromptInput, PromptInputTextarea, PromptInputSubmit } from '@/component import { Reasoning, ReasoningTrigger, ReasoningContent } from '@/components/ai-elements/reasoning'; import { Response as UIResponse } from '@/components/ai-elements/response'; import { AutoCompleteSelect } from '@/components/auto-complete-select'; -import { useQueryChannels } from '@/features/channels/data/channels'; +import { useAllChannelSummarys } from '@/features/channels/data/channels'; import { useQueryModels } from '@/features/models/data/models'; +import { usePermissions } from '@/hooks/usePermissions'; +import { cn } from '@/lib/utils'; type PlaygroundModelSource = 'channel' | 'model_gateway'; @@ -71,15 +73,11 @@ export default function Playground() { const { accessToken } = useAuthStore((state) => state.auth); const selectedProjectId = useSelectedProjectId(); + const { hasSystemScope } = usePermissions(); + const canUseModelGateway = hasSystemScope('read_channels'); // 获取 channels 数据 - const { data: channelsData, isLoading: channelsLoading } = useQueryChannels({ - first: 100, - orderBy: { field: 'ORDERING_WEIGHT', direction: 'DESC' }, - where: { - statusIn: ['enabled', 'disabled'], - }, - }); + const { data: channelsData, isLoading: channelsLoading } = useAllChannelSummarys(selectedProjectId); const isModelGatewaySource = modelSource === 'model_gateway'; const { data: modelsData, isLoading: modelsLoading } = useQueryModels( { @@ -90,7 +88,7 @@ export default function Playground() { typeIn: ['chat'], }, }, - { enabled: isModelGatewaySource } + { enabled: isModelGatewaySource && canUseModelGateway } ); const [input, setInput] = useState(''); @@ -232,7 +230,7 @@ export default function Playground() { const channelOptions = useMemo(() => { if (!channelsData?.edges) return []; return channelsData.edges - .filter((edge) => edge.node.supportedModels.length > 0) + .filter((edge) => edge.node.allModelEntries.length > 0) .map((edge) => ({ value: edge.node.id, label: edge.node.name, @@ -256,9 +254,9 @@ export default function Playground() { if (!channelsData?.edges || !selectedChannel) return []; const channelEdge = channelsData.edges.find((edge) => edge.node.id === selectedChannel); if (!channelEdge) return []; - return channelEdge.node.supportedModels.map((m) => ({ - value: m, - label: m, + return channelEdge.node.allModelEntries.map((entry) => ({ + value: entry.requestModel, + label: entry.requestModel, })); }, [channelsData, isModelGatewaySource, modelPageModelOptions, selectedChannel]); @@ -269,7 +267,7 @@ export default function Playground() { (channelId: string) => { setSelectedChannel(channelId); const channelEdge = channelsData?.edges?.find((edge) => edge.node.id === channelId); - const firstModel = channelEdge?.node.supportedModels[0] ?? ''; + const firstModel = channelEdge?.node.allModelEntries[0]?.requestModel ?? ''; setModel(firstModel); }, [channelsData] @@ -278,17 +276,26 @@ export default function Playground() { const handleModelSourceChange = useCallback( (source: string) => { const nextSource = source as PlaygroundModelSource; + if (nextSource === 'model_gateway' && !canUseModelGateway) { + return; + } setModelSource(nextSource); if (nextSource === 'model_gateway') { setModel(modelPageModelOptions[0]?.value ?? ''); return; } const channelEdge = channelsData?.edges?.find((edge) => edge.node.id === selectedChannel); - setModel(channelEdge?.node.supportedModels[0] ?? ''); + setModel(channelEdge?.node.allModelEntries[0]?.requestModel ?? ''); }, - [channelsData, modelPageModelOptions, selectedChannel] + [canUseModelGateway, channelsData, modelPageModelOptions, selectedChannel] ); + useEffect(() => { + if (!canUseModelGateway && modelSource === 'model_gateway') { + setModelSource('channel'); + } + }, [canUseModelGateway, modelSource]); + // 初始化:默认选第一个渠道和第一个模型 useEffect(() => { if (!selectedChannel && !channelsLoading && channelOptions.length > 0) { @@ -333,9 +340,9 @@ export default function Playground() {
- + {t('playground.settings.channel')} - {t('playground.settings.modelGateway')} + {canUseModelGateway && {t('playground.settings.modelGateway')}}
diff --git a/frontend/src/features/proejct-users/components/users-invite-dialog.tsx b/frontend/src/features/proejct-users/components/users-invite-dialog.tsx index a70b7048d..8caba0d56 100644 --- a/frontend/src/features/proejct-users/components/users-invite-dialog.tsx +++ b/frontend/src/features/proejct-users/components/users-invite-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { z } from 'zod'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -6,7 +6,9 @@ import { IconCheck, IconCopy, IconMailPlus } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { apiRequest } from '@/lib/api-client'; +import { extractNumberIDAsNumber } from '@/lib/utils'; import { useSelectedProjectId } from '@/stores/projectStore'; +import { useRoles } from '@/features/project-roles/data/roles'; import { Button } from '@/components/ui/button'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; @@ -17,6 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ const formSchema = z.object({ expiresInHours: z.enum(['1', '6', '24', '168', '0']), maxUses: z.enum(['1', '0']), + roleID: z.string().min(1), }); type InviteForm = z.infer; @@ -31,11 +34,21 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) { const selectedProjectId = useSelectedProjectId(); const [inviteLink, setInviteLink] = useState(''); const [isCopied, setIsCopied] = useState(false); + const { data: rolesData, isLoading: isLoadingRoles } = useRoles(); const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: { expiresInHours: '168', maxUses: '1' }, + defaultValues: { expiresInHours: '168', maxUses: '1', roleID: '' }, }); + const roles = rolesData?.edges.map((edge) => edge.node) || []; + + useEffect(() => { + if (form.getValues('roleID') || roles.length === 0) { + return; + } + form.setValue('roleID', roles.find((role) => role.name === 'Developer')?.id || roles[0].id); + }, [form, roles]); + const closeDialog = (nextOpen: boolean) => { if (!nextOpen) { form.reset(); @@ -58,6 +71,7 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) { body: { expiresInHours: Number(values.expiresInHours), maxUses: Number(values.maxUses), + roleID: extractNumberIDAsNumber(values.roleID), }, }); const url = new URL('/sign-up', window.location.origin); @@ -103,6 +117,28 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) { ) : (
+ ( + + {t('users.form.projectRoles')} + + + + )} + /> - diff --git a/frontend/src/features/project-roles/data/roles.ts b/frontend/src/features/project-roles/data/roles.ts index bc6c5f4c2..dfa76cb71 100644 --- a/frontend/src/features/project-roles/data/roles.ts +++ b/frontend/src/features/project-roles/data/roles.ts @@ -9,29 +9,25 @@ import { Role, RoleConnection, CreateRoleInput, UpdateRoleInput, roleConnectionS // GraphQL queries and mutations const PROJECT_ROLES_QUERY = ` - query GetProjectRoles($projectId: ID!, $first: Int, $after: Cursor, $orderBy: RoleOrder, $where: RoleWhereInput) { - node(id: $projectId) { - ... on Project { - roles(first: $first, after: $after, orderBy: $orderBy, where: $where) { - edges { - node { - id - createdAt - updatedAt - name - scopes - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount + query GetProjectRoles($first: Int, $after: Cursor, $orderBy: RoleOrder, $where: RoleWhereInput) { + roles(first: $first, after: $after, orderBy: $orderBy, where: $where) { + edges { + node { + id + createdAt + updatedAt + name + scopes } + cursor } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount } } `; @@ -80,6 +76,7 @@ export function useRoles( const selectedProjectId = useSelectedProjectId(); const queryVariables = { + first: 20, ...variables, orderBy: variables.orderBy || { field: 'CREATED_AT', direction: 'DESC' }, }; @@ -91,11 +88,16 @@ export function useRoles( if (!selectedProjectId) { throw new Error('Project ID is required'); } - const data = await graphqlRequest<{ node: { roles: RoleConnection } }>(PROJECT_ROLES_QUERY, { - projectId: selectedProjectId, - ...queryVariables, - }); - return roleConnectionSchema.parse(data?.node?.roles); + const data = await graphqlRequest<{ roles: RoleConnection }>( + PROJECT_ROLES_QUERY, + { + projectId: selectedProjectId, + ...queryVariables, + where: { and: [{ projectID: selectedProjectId }, ...(variables.where ? [variables.where] : [])] }, + }, + { 'X-Project-ID': selectedProjectId } + ); + return roleConnectionSchema.parse(data.roles); } catch (error) { handleError(error, t('common.errors.internalServerError')); throw error; @@ -117,11 +119,12 @@ export function useRole(id: string) { if (!selectedProjectId) { throw new Error('Project ID is required'); } - const data = await graphqlRequest<{ node: { roles: RoleConnection } }>(PROJECT_ROLES_QUERY, { - projectId: selectedProjectId, - where: { id }, - }); - const role = data.node?.roles?.edges[0]?.node; + const data = await graphqlRequest<{ roles: RoleConnection }>( + PROJECT_ROLES_QUERY, + { projectId: selectedProjectId, where: { and: [{ projectID: selectedProjectId }, { id }] } }, + { 'X-Project-ID': selectedProjectId } + ); + const role = data.roles?.edges[0]?.node; if (!role) { throw new Error('Role not found'); } @@ -151,7 +154,8 @@ export function useCreateRole() { level: 'project', projectID: input.projectID || selectedProjectId, }; - const data = await graphqlRequest<{ createRole: Role }>(CREATE_ROLE_MUTATION, { input: inputWithProjectId }); + const headers = selectedProjectId ? { 'X-Project-ID': selectedProjectId } : undefined; + const data = await graphqlRequest<{ createRole: Role }>(CREATE_ROLE_MUTATION, { input: inputWithProjectId }, headers); return roleSchema.parse(data.createRole); } catch (error) { handleError(error, { context: t('roles.dialogs.create.title') }); @@ -169,11 +173,13 @@ export function useUpdateRole() { const queryClient = useQueryClient(); const { handleError } = useErrorHandler(); const { t } = useTranslation(); + const selectedProjectId = useSelectedProjectId(); return useMutation({ mutationFn: async ({ id, input }: { id: string; input: UpdateRoleInput }) => { try { - const data = await graphqlRequest<{ updateRole: Role }>(UPDATE_ROLE_MUTATION, { id, input }); + const headers = selectedProjectId ? { 'X-Project-ID': selectedProjectId } : undefined; + const data = await graphqlRequest<{ updateRole: Role }>(UPDATE_ROLE_MUTATION, { id, input }, headers); return roleSchema.parse(data.updateRole); } catch (error) { handleError(error, { context: t('roles.dialogs.edit.title') }); @@ -191,11 +197,13 @@ export function useUpdateRole() { export function useDeleteRole() { const queryClient = useQueryClient(); const { handleError } = useErrorHandler(); + const selectedProjectId = useSelectedProjectId(); return useMutation({ mutationFn: async (id: string) => { try { - await graphqlRequest(DELETE_ROLE_MUTATION, { id }); + const headers = selectedProjectId ? { 'X-Project-ID': selectedProjectId } : undefined; + await graphqlRequest(DELETE_ROLE_MUTATION, { id }, headers); } catch (error) { handleError(error, i18n.t('common.errors.internalServerError')); throw error; diff --git a/frontend/src/features/prompts/components/prompts-columns.tsx b/frontend/src/features/prompts/components/prompts-columns.tsx index 804279c4c..8c9d41a6c 100644 --- a/frontend/src/features/prompts/components/prompts-columns.tsx +++ b/frontend/src/features/prompts/components/prompts-columns.tsx @@ -12,7 +12,7 @@ import { Prompt } from '../data/schema'; import { DataTableRowActions } from './data-table-row-actions'; import { PromptsStatusDialog } from './prompts-status-dialog'; -function StatusSwitchCell({ row }: { row: Row }) { +function StatusSwitchCell({ row, canWrite }: { row: Row; canWrite: boolean }) { const prompt = row.original; const [dialogOpen, setDialogOpen] = useState(false); @@ -22,6 +22,10 @@ function StatusSwitchCell({ row }: { row: Row }) { setDialogOpen(true); }, []); + if (!canWrite) { + return {prompt.status}; + } + return ( <> @@ -141,7 +145,7 @@ export const createColumns = (t: ReturnType['t'], canWrit { accessorKey: 'status', header: ({ column }) => , - cell: StatusSwitchCell, + cell: ({ row }) => , enableSorting: false, enableHiding: false, }, @@ -168,15 +172,15 @@ export const createColumns = (t: ReturnType['t'], canWrit enableSorting: true, enableHiding: false, }, - { - id: 'actions', - header: t('common.columns.actions'), - cell: DataTableRowActions, - meta: { - className: 'w-[56px] min-w-[56px] pr-3 pl-0', - }, - enableSorting: false, - enableHiding: false, - }, + ...(canWrite + ? [{ + id: 'actions', + header: t('common.columns.actions'), + cell: DataTableRowActions, + meta: { className: 'w-[56px] min-w-[56px] pr-3 pl-0' }, + enableSorting: false, + enableHiding: false, + }] + : []), ]; }; diff --git a/frontend/src/gql/models.ts b/frontend/src/gql/models.ts index 66fffc247..f974ba2b9 100644 --- a/frontend/src/gql/models.ts +++ b/frontend/src/gql/models.ts @@ -1,5 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { graphqlRequest } from './graphql'; +import { useSelectedProjectId } from '@/stores/projectStore'; export interface Model { id: string; @@ -27,11 +28,14 @@ const MODELS_QUERY = ` `; export function useQueryModels() { + const selectedProjectId = useSelectedProjectId(); + return useMutation({ mutationFn: async (input: QueryModelsInput = {}) => { + const headers = selectedProjectId ? { 'X-Project-ID': selectedProjectId } : undefined; const data = await graphqlRequest<{ queryModels: Model[]; - }>(MODELS_QUERY, { input }); + }>(MODELS_QUERY, { input }, headers); return data.queryModels; }, }); diff --git a/frontend/src/gql/users.ts b/frontend/src/gql/users.ts index 3a4a1670d..35b6604a7 100644 --- a/frontend/src/gql/users.ts +++ b/frontend/src/gql/users.ts @@ -24,6 +24,7 @@ export const ME_QUERY = ` projectID isOwner scopes + effectiveScopes roles { name } diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts index 955807dac..ab27571de 100644 --- a/frontend/src/hooks/usePermissions.ts +++ b/frontend/src/hooks/usePermissions.ts @@ -23,7 +23,7 @@ export function usePermissions() { return []; } const project = user.projects.find((p) => p.projectID === selectedProjectId); - return project?.scopes || []; + return project?.effectiveScopes || project?.scopes || []; }, [selectedProjectId, user?.projects]); const isProjectOwner = useMemo(() => { @@ -59,8 +59,8 @@ export function usePermissions() { // Check if user has a specific scope at project level only const hasProjectScope = useCallback( (requiredScope: string): boolean => { - // Owner has all permissions - if (isOwner) { + // Owners have all permissions in the selected project. + if (isProjectOwner) { return true; } @@ -76,7 +76,7 @@ export function usePermissions() { return false; }, - [user, isOwner, projectScopes] + [user, isProjectOwner, projectScopes] ); // Check if user has a specific scope (system-level or project-level) diff --git a/frontend/src/hooks/useRoutePermissions.ts b/frontend/src/hooks/useRoutePermissions.ts index 01829d465..d994f1f85 100644 --- a/frontend/src/hooks/useRoutePermissions.ts +++ b/frontend/src/hooks/useRoutePermissions.ts @@ -21,7 +21,7 @@ export function useRoutePermissions() { return []; } const project = user.projects.find((p) => p.projectID === selectedProjectId); - return project?.scopes || []; + return project?.effectiveScopes || project?.scopes || []; }, [selectedProjectId, user?.projects]); const isProjectOwner = useMemo(() => { @@ -46,14 +46,14 @@ export function useRoutePermissions() { return true; } + // 确定要检查的权限级别(路由配置优先,否则使用组配置,默认为 'any') + const scopeLevel = routeConfig.scopeLevel || groupScopeLevel || 'any'; + // Owner 拥有所有权限 - if (isOwner) { + if (isProjectOwner && scopeLevel !== 'system') { return true; } - // 确定要检查的权限级别(路由配置优先,否则使用组配置,默认为 'any') - const scopeLevel = routeConfig.scopeLevel || groupScopeLevel || 'any'; - // 根据 scopeLevel 决定检查哪些 scopes let scopesToCheck: string[] = []; diff --git a/frontend/src/lib/permission-utils.ts b/frontend/src/lib/permission-utils.ts index dcb4f7e5f..c572df54b 100644 --- a/frontend/src/lib/permission-utils.ts +++ b/frontend/src/lib/permission-utils.ts @@ -15,7 +15,7 @@ export function getUserScopes(user: AuthUser | null, projectId?: string | null): if (projectId) { const project = user.projects.find((p) => p.projectID === projectId); if (project) { - project.scopes.forEach((scope) => scopes.add(scope)); + (project.effectiveScopes || project.scopes).forEach((scope) => scopes.add(scope)); } } diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 48d59e447..1f32d7341 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -12,6 +12,7 @@ interface Project { projectID: string; isOwner: boolean; scopes: string[]; + effectiveScopes?: string[]; roles: Role[]; } diff --git a/internal/build/VERSION b/internal/build/VERSION index f81300bb9..7c5859d94 100644 --- a/internal/build/VERSION +++ b/internal/build/VERSION @@ -1 +1 @@ -v1.0.0-beta6 +v1.0.0-beta8 diff --git a/internal/ent/entql.go b/internal/ent/entql.go index 7480f6d91..58a3fff29 100644 --- a/internal/ent/entql.go +++ b/internal/ent/entql.go @@ -241,6 +241,7 @@ var schemaGraph = func() *sqlgraph.Schema { invitation.FieldDeletedAt: {Type: field.TypeInt, Column: invitation.FieldDeletedAt}, invitation.FieldTokenHash: {Type: field.TypeString, Column: invitation.FieldTokenHash}, invitation.FieldProjectID: {Type: field.TypeInt, Column: invitation.FieldProjectID}, + invitation.FieldRoleID: {Type: field.TypeInt, Column: invitation.FieldRoleID}, invitation.FieldExpiresAt: {Type: field.TypeTime, Column: invitation.FieldExpiresAt}, invitation.FieldMaxUses: {Type: field.TypeInt, Column: invitation.FieldMaxUses}, invitation.FieldUsedCount: {Type: field.TypeInt, Column: invitation.FieldUsedCount}, @@ -2439,6 +2440,11 @@ func (f *InvitationFilter) WhereProjectID(p entql.IntP) { f.Where(p.Field(invitation.FieldProjectID)) } +// WhereRoleID applies the entql int predicate on the role_id field. +func (f *InvitationFilter) WhereRoleID(p entql.IntP) { + f.Where(p.Field(invitation.FieldRoleID)) +} + // WhereExpiresAt applies the entql time.Time predicate on the expires_at field. func (f *InvitationFilter) WhereExpiresAt(p entql.TimeP) { f.Where(p.Field(invitation.FieldExpiresAt)) diff --git a/internal/ent/internal/schema.go b/internal/ent/internal/schema.go index 556ef0a58..1c66bd8bf 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},\"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},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"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\"},{\"N\":\"personal\",\"V\":\"personal\"}],\"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, noauth, or personal\"},{\"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}}},{\"name\":\"allowed_ips\",\"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\":\"IP CIDR allowlist for this API key. If non-empty, only requests from matching source IPs are accepted.\"}],\"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\":\"APIKeyProfileTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_key_profile_templates\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}}],\"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\":\"Template 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\":\"Template description\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, set via project edge\"},{\"name\":\"profile\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfile\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfile\",\"Ident\":\"objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Clone\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"*objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"\",\"Methods\":null}]},\"MatchChannelTags\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"default\":true,\"default_value\":{\"modelMappings\":null,\"name\":\"\"},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"api_key_profile_templates_by_project_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\":\"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\":\"atlascloud\",\"V\":\"atlascloud\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"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\":\"qiniu\",\"V\":\"qiniu\"},{\"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\":\"xiaomi_anthropic\",\"V\":\"xiaomi_anthropic\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"volcengine_anthropic\",\"V\":\"volcengine_anthropic\"},{\"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\":\"aihubmix_anthropic\",\"V\":\"aihubmix_anthropic\"},{\"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\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"opencode_go_anthropic\",\"V\":\"opencode_go_anthropic\"},{\"N\":\"ollama\",\"V\":\"ollama\"},{\"N\":\"ollama_anthropic\",\"V\":\"ollama_anthropic\"},{\"N\":\"evolink\",\"V\":\"evolink\"},{\"N\":\"evolink_anthropic\",\"V\":\"evolink_anthropic\"}],\"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,\"lowercaseModelId\":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\"},{\"name\":\"endpoints\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.ChannelEndpoint\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.ChannelEndpoint\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Outbound API endpoints for this channel. Each endpoint specifies api_format and optional path. When empty, defaults are derived from channel type.\"}],\"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\":\"Invitation\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"invitations\",\"unique\":true,\"inverse\":true,\"required\":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\":\"token_hash\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_uses\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"used_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"token_hash\"]},{\"fields\":[\"project_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"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\":\"OIDCIdentity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48},\"EntSQL\":{\"on_delete\":\"CASCADE\"}}}],\"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\":\"issuer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC provider issuer URL\"},{\"name\":\"subject\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC subject identifier\"},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Email from OIDC provider\"},{\"name\":\"idp_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Identity provider name\"},{\"name\":\"last_login_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Last login timestamp\"},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference to the User entity\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"issuer\",\"subject\",\"deleted_at\"],\"storage_key\":\"oidc_identities_by_issuer_subject_deleted_at\"},{\"fields\":[\"user_id\"],\"storage_key\":\"oidc_identities_by_user_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\":\"invitations\",\"type\":\"Invitation\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"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}}},{\"name\":\"api_key_profile_templates\",\"type\":\"APIKeyProfileTemplate\",\"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\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"prompts\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"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\":\"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},\"schema_type\":{\"mysql\":\"longtext\"},\"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\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"wafer\",\"V\":\"wafer\"},{\"N\":\"synthetic\",\"V\":\"synthetic\"},{\"N\":\"neuralwatt\",\"V\":\"neuralwatt\"},{\"N\":\"apertis\",\"V\":\"apertis\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"kimi_code\",\"V\":\"kimi_code\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"}],\"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\":\"reasoning_effort\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning effort used for reasoning models\"},{\"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\":7,\"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\":8,\"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\":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_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\":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\":\"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\":11,\"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\":12,\"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\":13,\"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\":14,\"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\":15,\"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\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_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_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"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\":19,\"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\":20,\"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\":21,\"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\":22,\"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\":23,\"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\"},{\"name\":\"request_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Actual upstream request URL sent to the provider\"},{\"name\":\"pass_through_applied\",\"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 pass-through was active for this execution attempt\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"request_id\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"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,\"default\":true,\"default_value\":0,\"default_kind\":2,\"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\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"thread.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"threads_by_project_id_status\"}],\"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\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"trace.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"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\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"traces_by_project_id_status\"}],\"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}}},{\"name\":\"oidc_identities\",\"type\":\"OIDCIdentity\",\"ref_name\":\"user\",\"inverse\":true,\"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},\"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},\"annotations\":{\"EntGQL\":{\"OrderField\":\"NAME\"}}},{\"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\"},{\"N\":\"personal\",\"V\":\"personal\"}],\"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, noauth, or personal\"},{\"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}}},{\"name\":\"allowed_ips\",\"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\":\"IP CIDR allowlist for this API key. If non-empty, only requests from matching source IPs are accepted.\"}],\"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\":\"APIKeyProfileTemplate\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"api_key_profile_templates\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"Skip\":32}}}],\"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\":\"Template 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\":\"Template description\"},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"immutable\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":32}},\"comment\":\"Project ID, set via project edge\"},{\"name\":\"profile\",\"type\":{\"Type\":3,\"Ident\":\"*objects.APIKeyProfile\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"APIKeyProfile\",\"Ident\":\"objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"Methods\":{\"Clone\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"*objects.APIKeyProfile\",\"Kind\":22,\"PkgPath\":\"\",\"Methods\":null}]},\"MatchChannelTags\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"default\":true,\"default_value\":{\"modelMappings\":null,\"name\":\"\"},\"default_kind\":22,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}}],\"indexes\":[{\"unique\":true,\"fields\":[\"project_id\",\"name\",\"deleted_at\"],\"storage_key\":\"api_key_profile_templates_by_project_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\":\"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\":\"atlascloud\",\"V\":\"atlascloud\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"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\":\"qiniu\",\"V\":\"qiniu\"},{\"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\":\"xiaomi_anthropic\",\"V\":\"xiaomi_anthropic\"},{\"N\":\"xai\",\"V\":\"xai\"},{\"N\":\"ppio\",\"V\":\"ppio\"},{\"N\":\"siliconflow\",\"V\":\"siliconflow\"},{\"N\":\"volcengine\",\"V\":\"volcengine\"},{\"N\":\"volcengine_anthropic\",\"V\":\"volcengine_anthropic\"},{\"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\":\"aihubmix_anthropic\",\"V\":\"aihubmix_anthropic\"},{\"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\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"opencode_go_anthropic\",\"V\":\"opencode_go_anthropic\"},{\"N\":\"ollama\",\"V\":\"ollama\"},{\"N\":\"ollama_anthropic\",\"V\":\"ollama_anthropic\"},{\"N\":\"evolink\",\"V\":\"evolink\"},{\"N\":\"evolink_anthropic\",\"V\":\"evolink_anthropic\"}],\"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,\"lowercaseModelId\":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\"},{\"name\":\"endpoints\",\"type\":{\"Type\":3,\"Ident\":\"[]objects.ChannelEndpoint\",\"PkgPath\":\"github.com/looplj/axonhub/internal/objects\",\"PkgName\":\"objects\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]objects.ChannelEndpoint\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":17,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Outbound API endpoints for this channel. Each endpoint specifies api_format and optional path. When empty, defaults are derived from channel type.\"}],\"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\":\"Invitation\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"invitations\",\"unique\":true,\"inverse\":true,\"required\":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\":\"token_hash\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"project_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"role_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_uses\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"used_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"token_hash\"]},{\"fields\":[\"project_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":1}],\"policy\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"Skip\":63}}},{\"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\":\"OIDCIdentity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"unique\":true,\"required\":true,\"annotations\":{\"EntGQL\":{\"Skip\":48},\"EntSQL\":{\"on_delete\":\"CASCADE\"}}}],\"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\":\"issuer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC provider issuer URL\"},{\"name\":\"subject\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"OIDC subject identifier\"},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Email from OIDC provider\"},{\"name\":\"idp_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Identity provider name\"},{\"name\":\"last_login_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Last login timestamp\"},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reference to the User entity\"}],\"indexes\":[{\"unique\":true,\"fields\":[\"issuer\",\"subject\",\"deleted_at\"],\"storage_key\":\"oidc_identities_by_issuer_subject_deleted_at\"},{\"fields\":[\"user_id\"],\"storage_key\":\"oidc_identities_by_user_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\":\"invitations\",\"type\":\"Invitation\",\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"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}}},{\"name\":\"api_key_profile_templates\",\"type\":\"APIKeyProfileTemplate\",\"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\":\"project\",\"type\":\"Project\",\"field\":\"project_id\",\"ref_name\":\"prompts\",\"unique\":true,\"inverse\":true,\"required\":true,\"immutable\":true,\"annotations\":{\"EntGQL\":{\"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\":\"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},\"schema_type\":{\"mysql\":\"longtext\"},\"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\"},{\"N\":\"cline\",\"V\":\"cline\"},{\"N\":\"wafer\",\"V\":\"wafer\"},{\"N\":\"synthetic\",\"V\":\"synthetic\"},{\"N\":\"neuralwatt\",\"V\":\"neuralwatt\"},{\"N\":\"apertis\",\"V\":\"apertis\"},{\"N\":\"opencode_go\",\"V\":\"opencode_go\"},{\"N\":\"kimi_code\",\"V\":\"kimi_code\"},{\"N\":\"minimax\",\"V\":\"minimax\"},{\"N\":\"zhipu\",\"V\":\"zhipu\"}],\"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\":\"reasoning_effort\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Reasoning effort used for reasoning models\"},{\"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\":7,\"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\":8,\"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\":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_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\":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\":\"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\":11,\"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\":12,\"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\":13,\"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\":14,\"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\":15,\"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\":16,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"metrics_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_first_token_latency_ms\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":18,\"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\":19,\"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\":20,\"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\":21,\"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\":22,\"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\":23,\"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\"},{\"name\":\"request_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":18,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Actual upstream request URL sent to the provider\"},{\"name\":\"pass_through_applied\",\"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 pass-through was active for this execution attempt\"}],\"indexes\":[{\"fields\":[\"request_id\",\"status\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_status_created_at\"},{\"fields\":[\"request_id\",\"created_at\"],\"storage_key\":\"request_executions_by_request_id_created_at\"},{\"fields\":[\"channel_id\",\"created_at\"],\"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,\"default\":true,\"default_value\":0,\"default_kind\":2,\"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\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"thread.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"indexes\":[{\"fields\":[\"project_id\"],\"storage_key\":\"threads_by_project_id\"},{\"unique\":true,\"fields\":[\"thread_id\"],\"storage_key\":\"threads_by_thread_id\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"threads_by_project_id_status\"}],\"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\"},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"trace.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"archived\",\"V\":\"archived\"},{\"N\":\"retained\",\"V\":\"retained\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Record status: active (default), archived (hidden), retained (protected from GC)\"}],\"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\"},{\"fields\":[\"project_id\",\"status\"],\"storage_key\":\"traces_by_project_id_status\"}],\"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}}},{\"name\":\"oidc_identities\",\"type\":\"OIDCIdentity\",\"ref_name\":\"user\",\"inverse\":true,\"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/invitation.go b/internal/ent/invitation.go index e6e979fdf..b22eed287 100644 --- a/internal/ent/invitation.go +++ b/internal/ent/invitation.go @@ -28,6 +28,8 @@ type Invitation struct { TokenHash string `json:"-"` // ProjectID holds the value of the "project_id" field. ProjectID int `json:"project_id,omitempty"` + // RoleID holds the value of the "role_id" field. + RoleID *int `json:"role_id,omitempty"` // ExpiresAt holds the value of the "expires_at" field. ExpiresAt *time.Time `json:"expires_at,omitempty"` // MaxUses holds the value of the "max_uses" field. @@ -67,7 +69,7 @@ func (*Invitation) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case invitation.FieldID, invitation.FieldDeletedAt, invitation.FieldProjectID, invitation.FieldMaxUses, invitation.FieldUsedCount: + case invitation.FieldID, invitation.FieldDeletedAt, invitation.FieldProjectID, invitation.FieldRoleID, invitation.FieldMaxUses, invitation.FieldUsedCount: values[i] = new(sql.NullInt64) case invitation.FieldTokenHash: values[i] = new(sql.NullString) @@ -124,6 +126,13 @@ func (_m *Invitation) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ProjectID = int(value.Int64) } + case invitation.FieldRoleID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field role_id", values[i]) + } else if value.Valid { + _m.RoleID = new(int) + *_m.RoleID = int(value.Int64) + } case invitation.FieldExpiresAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field expires_at", values[i]) @@ -198,6 +207,11 @@ func (_m *Invitation) String() string { builder.WriteString("project_id=") builder.WriteString(fmt.Sprintf("%v", _m.ProjectID)) builder.WriteString(", ") + if v := _m.RoleID; v != nil { + builder.WriteString("role_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") if v := _m.ExpiresAt; v != nil { builder.WriteString("expires_at=") builder.WriteString(v.Format(time.ANSIC)) diff --git a/internal/ent/invitation/invitation.go b/internal/ent/invitation/invitation.go index b8d1330a2..eb0b6fbb6 100644 --- a/internal/ent/invitation/invitation.go +++ b/internal/ent/invitation/invitation.go @@ -25,6 +25,8 @@ const ( FieldTokenHash = "token_hash" // FieldProjectID holds the string denoting the project_id field in the database. FieldProjectID = "project_id" + // FieldRoleID holds the string denoting the role_id field in the database. + FieldRoleID = "role_id" // FieldExpiresAt holds the string denoting the expires_at field in the database. FieldExpiresAt = "expires_at" // FieldMaxUses holds the string denoting the max_uses field in the database. @@ -52,6 +54,7 @@ var Columns = []string{ FieldDeletedAt, FieldTokenHash, FieldProjectID, + FieldRoleID, FieldExpiresAt, FieldMaxUses, FieldUsedCount, @@ -123,6 +126,11 @@ func ByProjectID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProjectID, opts...).ToFunc() } +// ByRoleID orders the results by the role_id field. +func ByRoleID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldRoleID, opts...).ToFunc() +} + // ByExpiresAt orders the results by the expires_at field. func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() diff --git a/internal/ent/invitation/where.go b/internal/ent/invitation/where.go index d357c5e0e..1e1a44830 100644 --- a/internal/ent/invitation/where.go +++ b/internal/ent/invitation/where.go @@ -80,6 +80,11 @@ func ProjectID(v int) predicate.Invitation { return predicate.Invitation(sql.FieldEQ(FieldProjectID, v)) } +// RoleID applies equality check predicate on the "role_id" field. It's identical to RoleIDEQ. +func RoleID(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldEQ(FieldRoleID, v)) +} + // ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. func ExpiresAt(v time.Time) predicate.Invitation { return predicate.Invitation(sql.FieldEQ(FieldExpiresAt, v)) @@ -300,6 +305,56 @@ func ProjectIDNotIn(vs ...int) predicate.Invitation { return predicate.Invitation(sql.FieldNotIn(FieldProjectID, vs...)) } +// RoleIDEQ applies the EQ predicate on the "role_id" field. +func RoleIDEQ(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldEQ(FieldRoleID, v)) +} + +// RoleIDNEQ applies the NEQ predicate on the "role_id" field. +func RoleIDNEQ(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldNEQ(FieldRoleID, v)) +} + +// RoleIDIn applies the In predicate on the "role_id" field. +func RoleIDIn(vs ...int) predicate.Invitation { + return predicate.Invitation(sql.FieldIn(FieldRoleID, vs...)) +} + +// RoleIDNotIn applies the NotIn predicate on the "role_id" field. +func RoleIDNotIn(vs ...int) predicate.Invitation { + return predicate.Invitation(sql.FieldNotIn(FieldRoleID, vs...)) +} + +// RoleIDGT applies the GT predicate on the "role_id" field. +func RoleIDGT(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldGT(FieldRoleID, v)) +} + +// RoleIDGTE applies the GTE predicate on the "role_id" field. +func RoleIDGTE(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldGTE(FieldRoleID, v)) +} + +// RoleIDLT applies the LT predicate on the "role_id" field. +func RoleIDLT(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldLT(FieldRoleID, v)) +} + +// RoleIDLTE applies the LTE predicate on the "role_id" field. +func RoleIDLTE(v int) predicate.Invitation { + return predicate.Invitation(sql.FieldLTE(FieldRoleID, v)) +} + +// RoleIDIsNil applies the IsNil predicate on the "role_id" field. +func RoleIDIsNil() predicate.Invitation { + return predicate.Invitation(sql.FieldIsNull(FieldRoleID)) +} + +// RoleIDNotNil applies the NotNil predicate on the "role_id" field. +func RoleIDNotNil() predicate.Invitation { + return predicate.Invitation(sql.FieldNotNull(FieldRoleID)) +} + // ExpiresAtEQ applies the EQ predicate on the "expires_at" field. func ExpiresAtEQ(v time.Time) predicate.Invitation { return predicate.Invitation(sql.FieldEQ(FieldExpiresAt, v)) diff --git a/internal/ent/invitation_create.go b/internal/ent/invitation_create.go index 3d5b9390e..6c2911b83 100644 --- a/internal/ent/invitation_create.go +++ b/internal/ent/invitation_create.go @@ -77,6 +77,20 @@ func (_c *InvitationCreate) SetProjectID(v int) *InvitationCreate { return _c } +// SetRoleID sets the "role_id" field. +func (_c *InvitationCreate) SetRoleID(v int) *InvitationCreate { + _c.mutation.SetRoleID(v) + return _c +} + +// SetNillableRoleID sets the "role_id" field if the given value is not nil. +func (_c *InvitationCreate) SetNillableRoleID(v *int) *InvitationCreate { + if v != nil { + _c.SetRoleID(*v) + } + return _c +} + // SetExpiresAt sets the "expires_at" field. func (_c *InvitationCreate) SetExpiresAt(v time.Time) *InvitationCreate { _c.mutation.SetExpiresAt(v) @@ -253,6 +267,10 @@ func (_c *InvitationCreate) createSpec() (*Invitation, *sqlgraph.CreateSpec) { _spec.SetField(invitation.FieldTokenHash, field.TypeString, value) _node.TokenHash = value } + if value, ok := _c.mutation.RoleID(); ok { + _spec.SetField(invitation.FieldRoleID, field.TypeInt, value) + _node.RoleID = &value + } if value, ok := _c.mutation.ExpiresAt(); ok { _spec.SetField(invitation.FieldExpiresAt, field.TypeTime, value) _node.ExpiresAt = &value @@ -388,6 +406,30 @@ func (u *InvitationUpsert) UpdateProjectID() *InvitationUpsert { return u } +// SetRoleID sets the "role_id" field. +func (u *InvitationUpsert) SetRoleID(v int) *InvitationUpsert { + u.Set(invitation.FieldRoleID, v) + return u +} + +// UpdateRoleID sets the "role_id" field to the value that was provided on create. +func (u *InvitationUpsert) UpdateRoleID() *InvitationUpsert { + u.SetExcluded(invitation.FieldRoleID) + return u +} + +// AddRoleID adds v to the "role_id" field. +func (u *InvitationUpsert) AddRoleID(v int) *InvitationUpsert { + u.Add(invitation.FieldRoleID, v) + return u +} + +// ClearRoleID clears the value of the "role_id" field. +func (u *InvitationUpsert) ClearRoleID() *InvitationUpsert { + u.SetNull(invitation.FieldRoleID) + return u +} + // SetExpiresAt sets the "expires_at" field. func (u *InvitationUpsert) SetExpiresAt(v time.Time) *InvitationUpsert { u.Set(invitation.FieldExpiresAt, v) @@ -550,6 +592,34 @@ func (u *InvitationUpsertOne) UpdateProjectID() *InvitationUpsertOne { }) } +// SetRoleID sets the "role_id" field. +func (u *InvitationUpsertOne) SetRoleID(v int) *InvitationUpsertOne { + return u.Update(func(s *InvitationUpsert) { + s.SetRoleID(v) + }) +} + +// AddRoleID adds v to the "role_id" field. +func (u *InvitationUpsertOne) AddRoleID(v int) *InvitationUpsertOne { + return u.Update(func(s *InvitationUpsert) { + s.AddRoleID(v) + }) +} + +// UpdateRoleID sets the "role_id" field to the value that was provided on create. +func (u *InvitationUpsertOne) UpdateRoleID() *InvitationUpsertOne { + return u.Update(func(s *InvitationUpsert) { + s.UpdateRoleID() + }) +} + +// ClearRoleID clears the value of the "role_id" field. +func (u *InvitationUpsertOne) ClearRoleID() *InvitationUpsertOne { + return u.Update(func(s *InvitationUpsert) { + s.ClearRoleID() + }) +} + // SetExpiresAt sets the "expires_at" field. func (u *InvitationUpsertOne) SetExpiresAt(v time.Time) *InvitationUpsertOne { return u.Update(func(s *InvitationUpsert) { @@ -887,6 +957,34 @@ func (u *InvitationUpsertBulk) UpdateProjectID() *InvitationUpsertBulk { }) } +// SetRoleID sets the "role_id" field. +func (u *InvitationUpsertBulk) SetRoleID(v int) *InvitationUpsertBulk { + return u.Update(func(s *InvitationUpsert) { + s.SetRoleID(v) + }) +} + +// AddRoleID adds v to the "role_id" field. +func (u *InvitationUpsertBulk) AddRoleID(v int) *InvitationUpsertBulk { + return u.Update(func(s *InvitationUpsert) { + s.AddRoleID(v) + }) +} + +// UpdateRoleID sets the "role_id" field to the value that was provided on create. +func (u *InvitationUpsertBulk) UpdateRoleID() *InvitationUpsertBulk { + return u.Update(func(s *InvitationUpsert) { + s.UpdateRoleID() + }) +} + +// ClearRoleID clears the value of the "role_id" field. +func (u *InvitationUpsertBulk) ClearRoleID() *InvitationUpsertBulk { + return u.Update(func(s *InvitationUpsert) { + s.ClearRoleID() + }) +} + // SetExpiresAt sets the "expires_at" field. func (u *InvitationUpsertBulk) SetExpiresAt(v time.Time) *InvitationUpsertBulk { return u.Update(func(s *InvitationUpsert) { diff --git a/internal/ent/invitation_update.go b/internal/ent/invitation_update.go index 6f0a9d9ee..5e358380a 100644 --- a/internal/ent/invitation_update.go +++ b/internal/ent/invitation_update.go @@ -85,6 +85,33 @@ func (_u *InvitationUpdate) SetNillableProjectID(v *int) *InvitationUpdate { return _u } +// SetRoleID sets the "role_id" field. +func (_u *InvitationUpdate) SetRoleID(v int) *InvitationUpdate { + _u.mutation.ResetRoleID() + _u.mutation.SetRoleID(v) + return _u +} + +// SetNillableRoleID sets the "role_id" field if the given value is not nil. +func (_u *InvitationUpdate) SetNillableRoleID(v *int) *InvitationUpdate { + if v != nil { + _u.SetRoleID(*v) + } + return _u +} + +// AddRoleID adds value to the "role_id" field. +func (_u *InvitationUpdate) AddRoleID(v int) *InvitationUpdate { + _u.mutation.AddRoleID(v) + return _u +} + +// ClearRoleID clears the value of the "role_id" field. +func (_u *InvitationUpdate) ClearRoleID() *InvitationUpdate { + _u.mutation.ClearRoleID() + return _u +} + // SetExpiresAt sets the "expires_at" field. func (_u *InvitationUpdate) SetExpiresAt(v time.Time) *InvitationUpdate { _u.mutation.SetExpiresAt(v) @@ -243,6 +270,15 @@ func (_u *InvitationUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.TokenHash(); ok { _spec.SetField(invitation.FieldTokenHash, field.TypeString, value) } + if value, ok := _u.mutation.RoleID(); ok { + _spec.SetField(invitation.FieldRoleID, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedRoleID(); ok { + _spec.AddField(invitation.FieldRoleID, field.TypeInt, value) + } + if _u.mutation.RoleIDCleared() { + _spec.ClearField(invitation.FieldRoleID, field.TypeInt) + } if value, ok := _u.mutation.ExpiresAt(); ok { _spec.SetField(invitation.FieldExpiresAt, field.TypeTime, value) } @@ -367,6 +403,33 @@ func (_u *InvitationUpdateOne) SetNillableProjectID(v *int) *InvitationUpdateOne return _u } +// SetRoleID sets the "role_id" field. +func (_u *InvitationUpdateOne) SetRoleID(v int) *InvitationUpdateOne { + _u.mutation.ResetRoleID() + _u.mutation.SetRoleID(v) + return _u +} + +// SetNillableRoleID sets the "role_id" field if the given value is not nil. +func (_u *InvitationUpdateOne) SetNillableRoleID(v *int) *InvitationUpdateOne { + if v != nil { + _u.SetRoleID(*v) + } + return _u +} + +// AddRoleID adds value to the "role_id" field. +func (_u *InvitationUpdateOne) AddRoleID(v int) *InvitationUpdateOne { + _u.mutation.AddRoleID(v) + return _u +} + +// ClearRoleID clears the value of the "role_id" field. +func (_u *InvitationUpdateOne) ClearRoleID() *InvitationUpdateOne { + _u.mutation.ClearRoleID() + return _u +} + // SetExpiresAt sets the "expires_at" field. func (_u *InvitationUpdateOne) SetExpiresAt(v time.Time) *InvitationUpdateOne { _u.mutation.SetExpiresAt(v) @@ -555,6 +618,15 @@ func (_u *InvitationUpdateOne) sqlSave(ctx context.Context) (_node *Invitation, if value, ok := _u.mutation.TokenHash(); ok { _spec.SetField(invitation.FieldTokenHash, field.TypeString, value) } + if value, ok := _u.mutation.RoleID(); ok { + _spec.SetField(invitation.FieldRoleID, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedRoleID(); ok { + _spec.AddField(invitation.FieldRoleID, field.TypeInt, value) + } + if _u.mutation.RoleIDCleared() { + _spec.ClearField(invitation.FieldRoleID, field.TypeInt) + } if value, ok := _u.mutation.ExpiresAt(); ok { _spec.SetField(invitation.FieldExpiresAt, field.TypeTime, value) } diff --git a/internal/ent/migrate/datamigrate/migrator.go b/internal/ent/migrate/datamigrate/migrator.go index c8a2c8bcd..dd70e4950 100644 --- a/internal/ent/migrate/datamigrate/migrator.go +++ b/internal/ent/migrate/datamigrate/migrator.go @@ -31,6 +31,8 @@ func NewMigrator(client *ent.Client) *Migrator { migrator.Register(NewV0_3_0()) migrator.Register(NewV0_4_0()) migrator.Register(NewV1_0_0_Beta6()) + migrator.Register(NewV1_0_0_Beta7()) + migrator.Register(NewV1_0_0_Beta8()) return migrator } diff --git a/internal/ent/migrate/datamigrate/v0.3.0_test.go b/internal/ent/migrate/datamigrate/v0.3.0_test.go index 073901cd9..abbc9f632 100644 --- a/internal/ent/migrate/datamigrate/v0.3.0_test.go +++ b/internal/ent/migrate/datamigrate/v0.3.0_test.go @@ -334,24 +334,29 @@ func TestV0_3_0_VerifyRoleScopes(t *testing.T) { assert.Contains(t, adminRole.Scopes, "write_api_keys") assert.Contains(t, adminRole.Scopes, "read_requests") assert.Contains(t, adminRole.Scopes, "write_requests") + assert.Contains(t, adminRole.Scopes, "read_prompts") + assert.Contains(t, adminRole.Scopes, "write_prompts") // Verify developer role has correct scopes developerRole, err := client.Role.Query().Where(role.NameEQ("Developer")).Only(ctx) require.NoError(t, err) - assert.Contains(t, developerRole.Scopes, "read_users") assert.Contains(t, developerRole.Scopes, "read_api_keys") assert.Contains(t, developerRole.Scopes, "write_api_keys") assert.Contains(t, developerRole.Scopes, "read_requests") + assert.Contains(t, developerRole.Scopes, "write_requests") + assert.NotContains(t, developerRole.Scopes, "read_prompts") + assert.NotContains(t, developerRole.Scopes, "write_prompts") assert.NotContains(t, developerRole.Scopes, "write_users") assert.NotContains(t, developerRole.Scopes, "write_roles") // Verify viewer role has correct scopes viewerRole, err := client.Role.Query().Where(role.NameEQ("Viewer")).Only(ctx) require.NoError(t, err) - assert.Contains(t, viewerRole.Scopes, "read_users") + assert.Contains(t, viewerRole.Scopes, "read_prompts") assert.Contains(t, viewerRole.Scopes, "read_requests") assert.NotContains(t, viewerRole.Scopes, "write_users") assert.NotContains(t, viewerRole.Scopes, "write_api_keys") + assert.NotContains(t, viewerRole.Scopes, "write_prompts") } func TestV0_3_0_AssignUsersToDefaultProject(t *testing.T) { diff --git a/internal/ent/migrate/datamigrate/v1.0.0-beta7.go b/internal/ent/migrate/datamigrate/v1.0.0-beta7.go new file mode 100644 index 000000000..6274e5366 --- /dev/null +++ b/internal/ent/migrate/datamigrate/v1.0.0-beta7.go @@ -0,0 +1,115 @@ +package datamigrate + +import ( + "context" + "fmt" + "time" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/invitation" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/ent/schema/schematype" + "github.com/looplj/axonhub/internal/ent/userrole" + "github.com/looplj/axonhub/internal/scopes" +) + +// V1_0_0_Beta7 implements DataMigrator for version 1.0.0-beta7 migration. +type V1_0_0_Beta7 struct{} + +// NewV1_0_0_Beta7 creates the v1.0.0-beta7 data migrator. +func NewV1_0_0_Beta7() DataMigrator { + return &V1_0_0_Beta7{} +} + +// Version returns the migration version. +func (v *V1_0_0_Beta7) Version() string { + return "v1.0.0-beta7" +} + +// Migrate binds legacy invitations to the project's default Developer role. +func (v *V1_0_0_Beta7) Migrate(ctx context.Context, client *ent.Client) (err error) { + ctx = authz.WithSystemBypass(ctx, "database-migrate") + ctx, tx, err := client.OpenTx(ctx) + if err != nil { + return err + } + + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + txClient := ent.FromContext(ctx) + + legacyInvitations, err := txClient.Invitation.Query().Where( + invitation.RoleIDIsNil(), + invitation.DeletedAtEQ(0), + ).All(ctx) + if err != nil { + return err + } + activeInvitations := legacyInvitations[:0] + now := time.Now() + for _, legacyInvitation := range legacyInvitations { + if legacyInvitation.ExpiresAt != nil && !legacyInvitation.ExpiresAt.After(now) { + continue + } + if legacyInvitation.MaxUses > 0 && legacyInvitation.UsedCount >= legacyInvitation.MaxUses { + continue + } + activeInvitations = append(activeInvitations, legacyInvitation) + } + legacyInvitations = activeInvitations + + for _, legacyInvitation := range legacyInvitations { + // Remove a stale project role only when this project needs invitation backfill. + softDeletedDevelopers, err := txClient.Role.Query().Where( + role.LevelEQ(role.LevelProject), + role.ProjectIDEQ(legacyInvitation.ProjectID), + role.NameEQ("Developer"), + ).All(schematype.SkipSoftDelete(ctx)) + if err != nil { + return fmt.Errorf("query soft-deleted Developer roles for project %d: %w", legacyInvitation.ProjectID, err) + } + for _, dr := range softDeletedDevelopers { + if dr.DeletedAt == 0 { + continue + } + if _, err := txClient.UserRole.Delete().Where(userrole.RoleID(dr.ID)).Exec(ctx); err != nil { + return fmt.Errorf("clear stale UserRole rows for Developer role %d: %w", dr.ID, err) + } + if err := txClient.Role.DeleteOneID(dr.ID).Exec(schematype.SkipSoftDelete(ctx)); err != nil { + return fmt.Errorf("permanently delete soft-deleted Developer role %d: %w", dr.ID, err) + } + } + + developerRole, err := txClient.Role.Query().Where( + role.LevelEQ(role.LevelProject), + role.ProjectIDEQ(legacyInvitation.ProjectID), + role.NameEQ("Developer"), + ).Only(ctx) + if ent.IsNotFound(err) { + developerRole, err = txClient.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(legacyInvitation.ProjectID). + SetScopes([]string{ + string(scopes.ScopeReadAPIKeys), + string(scopes.ScopeWriteAPIKeys), + string(scopes.ScopeReadRequests), + string(scopes.ScopeWriteRequests), + }). + Save(ctx) + } + if err != nil { + return fmt.Errorf("find Developer role for legacy invitation %d: %w", legacyInvitation.ID, err) + } + if err := txClient.Invitation.UpdateOneID(legacyInvitation.ID).SetRoleID(developerRole.ID).Exec(ctx); err != nil { + return fmt.Errorf("assign Developer role to legacy invitation %d: %w", legacyInvitation.ID, err) + } + } + + return tx.Commit() +} diff --git a/internal/ent/migrate/datamigrate/v1.0.0-beta7_test.go b/internal/ent/migrate/datamigrate/v1.0.0-beta7_test.go new file mode 100644 index 000000000..51f2cafed --- /dev/null +++ b/internal/ent/migrate/datamigrate/v1.0.0-beta7_test.go @@ -0,0 +1,233 @@ +package datamigrate_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/invitation" + "github.com/looplj/axonhub/internal/ent/migrate/datamigrate" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/ent/schema/schematype" + "github.com/looplj/axonhub/internal/ent/user" + "github.com/looplj/axonhub/internal/ent/userrole" +) + +func TestV1_0_0_Beta7_BackfillsLegacyInvitationRoles(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:legacy-invitation-role?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("legacy-invitation-project").SaveX(ctx) + customDeveloperRole := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"*"}). + SaveX(ctx) + legacyInvitation := client.Invitation.Create().SetTokenHash("legacy-token").SetProjectID(project.ID).SaveX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + legacyInvitation = client.Invitation.GetX(ctx, legacyInvitation.ID) + require.NotNil(t, legacyInvitation.RoleID) + developerRole := client.Role.Query().Where( + role.LevelEQ(role.LevelProject), + role.ProjectIDEQ(project.ID), + role.NameEQ("Developer"), + ).OnlyX(ctx) + require.Equal(t, customDeveloperRole.ID, developerRole.ID) + require.Equal(t, []string{"*"}, developerRole.Scopes) + require.Equal(t, developerRole.ID, *legacyInvitation.RoleID) + backfilled, err := client.Invitation.Query().Where(invitation.IDEQ(legacyInvitation.ID), invitation.RoleIDEQ(developerRole.ID)).Exist(ctx) + require.NoError(t, err) + require.True(t, backfilled) +} + +func TestV1_0_0_Beta7_CreatesMissingDefaultDeveloperRole(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:missing-default-developer-role?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("missing-default-developer-project").SaveX(ctx) + legacyInvitation := client.Invitation.Create().SetTokenHash("missing-default-developer-token").SetProjectID(project.ID).SaveX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + developerRole := client.Role.Query().Where(role.LevelEQ(role.LevelProject), role.ProjectIDEQ(project.ID), role.NameEQ("Developer")).OnlyX(ctx) + require.ElementsMatch(t, []string{"read_api_keys", "write_api_keys", "read_requests", "write_requests"}, developerRole.Scopes) + legacyInvitation = client.Invitation.GetX(ctx, legacyInvitation.ID) + require.NotNil(t, legacyInvitation.RoleID) + require.Equal(t, developerRole.ID, *legacyInvitation.RoleID) +} + +func TestV1_0_0_Beta7_SkipsRevokedLegacyInvitations(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:revoked-legacy-invitation?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("revoked-legacy-invitation-project").SaveX(ctx) + revoked := client.Invitation.Create().SetTokenHash("revoked-legacy-token").SetProjectID(project.ID).SaveX(ctx) + require.NoError(t, client.Invitation.DeleteOneID(revoked.ID).Exec(ctx)) + + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + revokedInvitation := client.Invitation.Query().Where(invitation.IDEQ(revoked.ID)).OnlyX(schematype.SkipSoftDelete(ctx)) + require.Nil(t, revokedInvitation.RoleID) +} + +func TestV1_0_0_Beta7_PreservesUnrelatedSoftDeletedDeveloper(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:unrelated-soft-deleted-developer?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("unrelated-soft-deleted-developer-project").SaveX(ctx) + developerRole := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"old_scope"}). + SaveX(ctx) + testUser := client.User.Create(). + SetEmail("unrelated-role-user@example.com"). + SetPassword("password"). + SetStatus(user.StatusActivated). + SaveX(ctx) + client.UserRole.Create().SetUserID(testUser.ID).SetRoleID(developerRole.ID).SaveX(ctx) + client.Role.DeleteOneID(developerRole.ID).ExecX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + preservedRole := client.Role.Query().Where(role.IDEQ(developerRole.ID)).OnlyX(schematype.SkipSoftDelete(ctx)) + require.NotEqual(t, 0, preservedRole.DeletedAt) + require.Equal(t, 1, client.UserRole.Query().Where(userrole.RoleID(developerRole.ID)).CountX(schematype.SkipSoftDelete(ctx))) +} + +func TestV1_0_0_Beta7_CleansSoftDeletedDeveloperAndRecreates(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:soft-deleted-developer-cleanup?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("soft-deleted-developer-project").SaveX(ctx) + + // Create a Developer role with users. + oldDeveloperRole, err := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"old_scope"}). + Save(ctx) + require.NoError(t, err) + + // Create a user and assign the role. + testUser, err := client.User.Create(). + SetEmail("user@example.com"). + SetPassword("password"). + SetStatus(user.StatusActivated). + Save(ctx) + require.NoError(t, err) + + _, err = client.UserRole.Create(). + SetUserID(testUser.ID). + SetRoleID(oldDeveloperRole.ID). + Save(ctx) + require.NoError(t, err) + + // Soft-delete the Developer role. + require.NoError(t, client.Role.DeleteOneID(oldDeveloperRole.ID).Exec(ctx)) + + // Verify UserRole relationship still exists (soft delete doesn't cascade). + count, err := client.UserRole.Query().Where(userrole.RoleID(oldDeveloperRole.ID)).Count(schematype.SkipSoftDelete(ctx)) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Create a legacy invitation. + legacyInvitation, err := client.Invitation.Create(). + SetTokenHash("soft-deleted-cleanup-token"). + SetProjectID(project.ID). + Save(ctx) + require.NoError(t, err) + + // Run migration. + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + // Verify old soft-deleted role is permanently deleted. + exists, err := client.Role.Query().Where(role.IDEQ(oldDeveloperRole.ID)).Exist(schematype.SkipSoftDelete(ctx)) + require.NoError(t, err) + require.False(t, exists) + + // Verify stale UserRole relationship is cleaned up. + count, err = client.UserRole.Query().Where(userrole.RoleID(oldDeveloperRole.ID)).Count(schematype.SkipSoftDelete(ctx)) + require.NoError(t, err) + require.Equal(t, 0, count) + + // Verify new Developer role is created with default scopes. + newDeveloperRole, err := client.Role.Query().Where( + role.LevelEQ(role.LevelProject), + role.ProjectIDEQ(project.ID), + role.NameEQ("Developer"), + ).Only(ctx) + require.NoError(t, err) + require.NotEqual(t, oldDeveloperRole.ID, newDeveloperRole.ID) + require.ElementsMatch(t, []string{ + "read_api_keys", "write_api_keys", + "read_requests", "write_requests", + }, newDeveloperRole.Scopes) + + // Verify the user is NOT assigned to the new role. + count, err = client.UserRole.Query().Where(userrole.RoleID(newDeveloperRole.ID)).Count(ctx) + require.NoError(t, err) + require.Equal(t, 0, count) + + // Verify legacy invitation is assigned to the new role. + legacyInvitation = client.Invitation.GetX(ctx, legacyInvitation.ID) + require.NotNil(t, legacyInvitation.RoleID) + require.Equal(t, newDeveloperRole.ID, *legacyInvitation.RoleID) +} + +func TestV1_0_0_Beta7_SkipsExpiredAndExhaustedInvitations(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:invalid-legacy-invitations?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("invalid-legacy-invitations-project").SaveX(ctx) + developerRole := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"old_scope"}). + SaveX(ctx) + testUser := client.User.Create(). + SetEmail("invalid-invitation-user@example.com"). + SetPassword("password"). + SetStatus(user.StatusActivated). + SaveX(ctx) + client.UserRole.Create().SetUserID(testUser.ID).SetRoleID(developerRole.ID).SaveX(ctx) + client.Role.DeleteOneID(developerRole.ID).ExecX(ctx) + expired := client.Invitation.Create(). + SetTokenHash("expired-legacy-token"). + SetProjectID(project.ID). + SetExpiresAt(time.Now().Add(-time.Hour)). + SaveX(ctx) + exhausted := client.Invitation.Create(). + SetTokenHash("exhausted-legacy-token"). + SetProjectID(project.ID). + SetMaxUses(1). + SetUsedCount(1). + SaveX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta7().Migrate(ctx, client)) + + expired = client.Invitation.GetX(ctx, expired.ID) + exhausted = client.Invitation.GetX(ctx, exhausted.ID) + require.Nil(t, expired.RoleID) + require.Nil(t, exhausted.RoleID) + preservedRole := client.Role.Query().Where(role.IDEQ(developerRole.ID)).OnlyX(schematype.SkipSoftDelete(ctx)) + require.NotEqual(t, 0, preservedRole.DeletedAt) + require.Equal(t, 1, client.UserRole.Query().Where(userrole.RoleID(developerRole.ID)).CountX(schematype.SkipSoftDelete(ctx))) +} diff --git a/internal/ent/migrate/datamigrate/v1.0.0-beta8.go b/internal/ent/migrate/datamigrate/v1.0.0-beta8.go new file mode 100644 index 000000000..986ad6711 --- /dev/null +++ b/internal/ent/migrate/datamigrate/v1.0.0-beta8.go @@ -0,0 +1,74 @@ +package datamigrate + +import ( + "context" + "fmt" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/scopes" +) + +// V1_0_0_Beta8 updates the default project Developer role scopes. +type V1_0_0_Beta8 struct{} + +// NewV1_0_0_Beta8 creates the v1.0.0-beta8 data migrator. +func NewV1_0_0_Beta8() DataMigrator { + return &V1_0_0_Beta8{} +} + +// Version returns the migration version. +func (v *V1_0_0_Beta8) Version() string { + return "v1.0.0-beta8" +} + +// Migrate removes prompt permissions and adds request read access to unchanged Developer presets. +func (v *V1_0_0_Beta8) Migrate(ctx context.Context, client *ent.Client) (err error) { + ctx = authz.WithSystemBypass(ctx, "database-migrate") + ctx, tx, err := client.OpenTx(ctx) + if err != nil { + return err + } + + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + txClient := ent.FromContext(ctx) + roles, err := txClient.Role.Query().Where( + role.LevelEQ(role.LevelProject), + role.NameEQ("Developer"), + ).All(ctx) + if err != nil { + return fmt.Errorf("query project Developer roles: %w", err) + } + + legacyScopes := []string{ + string(scopes.ScopeReadAPIKeys), + string(scopes.ScopeWriteAPIKeys), + string(scopes.ScopeReadPrompts), + string(scopes.ScopeWritePrompts), + string(scopes.ScopeWriteRequests), + } + defaultScopes := []string{ + string(scopes.ScopeReadAPIKeys), + string(scopes.ScopeWriteAPIKeys), + string(scopes.ScopeReadRequests), + string(scopes.ScopeWriteRequests), + } + + for _, developerRole := range roles { + if !sameScopes(developerRole.Scopes, legacyScopes) { + continue + } + + if err := txClient.Role.UpdateOneID(developerRole.ID).SetScopes(defaultScopes).Exec(ctx); err != nil { + return fmt.Errorf("update Developer role %d scopes: %w", developerRole.ID, err) + } + } + + return tx.Commit() +} diff --git a/internal/ent/migrate/datamigrate/v1.0.0-beta8_test.go b/internal/ent/migrate/datamigrate/v1.0.0-beta8_test.go new file mode 100644 index 000000000..83f474d9b --- /dev/null +++ b/internal/ent/migrate/datamigrate/v1.0.0-beta8_test.go @@ -0,0 +1,49 @@ +package datamigrate_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/migrate/datamigrate" + "github.com/looplj/axonhub/internal/ent/role" +) + +func TestV1_0_0_Beta8_UpdatesUnchangedDeveloperPreset(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:developer-scope-update?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("developer-scope-update-project").SaveX(ctx) + developerRole := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"read_api_keys", "write_api_keys", "read_prompts", "write_prompts", "write_requests"}). + SaveX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta8().Migrate(ctx, client)) + updated := client.Role.GetX(ctx, developerRole.ID) + require.ElementsMatch(t, []string{"read_api_keys", "write_api_keys", "read_requests", "write_requests"}, updated.Scopes) +} + +func TestV1_0_0_Beta8_PreservesCustomizedDeveloperRole(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:developer-scope-custom?mode=memory&_fk=1") + defer client.Close() + + ctx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("developer-scope-custom-project").SaveX(ctx) + developerRole := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"read_api_keys", "write_api_keys", "write_requests", "custom_scope"}). + SaveX(ctx) + + require.NoError(t, datamigrate.NewV1_0_0_Beta8().Migrate(ctx, client)) + updated := client.Role.GetX(ctx, developerRole.ID) + require.ElementsMatch(t, []string{"read_api_keys", "write_api_keys", "write_requests", "custom_scope"}, updated.Scopes) +} diff --git a/internal/ent/migrate/schema.go b/internal/ent/migrate/schema.go index 865a73bc2..b542e194b 100644 --- a/internal/ent/migrate/schema.go +++ b/internal/ent/migrate/schema.go @@ -290,6 +290,7 @@ var ( {Name: "updated_at", Type: field.TypeTime, Default: schema.Expr("CURRENT_TIMESTAMP")}, {Name: "deleted_at", Type: field.TypeInt, Default: 0}, {Name: "token_hash", Type: field.TypeString}, + {Name: "role_id", Type: field.TypeInt, Nullable: true}, {Name: "expires_at", Type: field.TypeTime, Nullable: true}, {Name: "max_uses", Type: field.TypeInt, Default: 1}, {Name: "used_count", Type: field.TypeInt, Default: 0}, @@ -303,7 +304,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "invitations_projects_invitations", - Columns: []*schema.Column{InvitationsColumns[8]}, + Columns: []*schema.Column{InvitationsColumns[9]}, RefColumns: []*schema.Column{ProjectsColumns[0]}, OnDelete: schema.NoAction, }, @@ -317,7 +318,7 @@ var ( { Name: "invitation_project_id", Unique: false, - Columns: []*schema.Column{InvitationsColumns[8]}, + Columns: []*schema.Column{InvitationsColumns[9]}, }, }, } diff --git a/internal/ent/mutation.go b/internal/ent/mutation.go index 33fb3f70f..45368716e 100644 --- a/internal/ent/mutation.go +++ b/internal/ent/mutation.go @@ -8950,6 +8950,8 @@ type InvitationMutation struct { deleted_at *int adddeleted_at *int token_hash *string + role_id *int + addrole_id *int expires_at *time.Time max_uses *int addmax_uses *int @@ -9261,6 +9263,76 @@ func (m *InvitationMutation) ResetProjectID() { m.project = nil } +// SetRoleID sets the "role_id" field. +func (m *InvitationMutation) SetRoleID(i int) { + m.role_id = &i + m.addrole_id = nil +} + +// RoleID returns the value of the "role_id" field in the mutation. +func (m *InvitationMutation) RoleID() (r int, exists bool) { + v := m.role_id + if v == nil { + return + } + return *v, true +} + +// OldRoleID returns the old "role_id" field's value of the Invitation entity. +// If the Invitation 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 *InvitationMutation) OldRoleID(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldRoleID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldRoleID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldRoleID: %w", err) + } + return oldValue.RoleID, nil +} + +// AddRoleID adds i to the "role_id" field. +func (m *InvitationMutation) AddRoleID(i int) { + if m.addrole_id != nil { + *m.addrole_id += i + } else { + m.addrole_id = &i + } +} + +// AddedRoleID returns the value that was added to the "role_id" field in this mutation. +func (m *InvitationMutation) AddedRoleID() (r int, exists bool) { + v := m.addrole_id + if v == nil { + return + } + return *v, true +} + +// ClearRoleID clears the value of the "role_id" field. +func (m *InvitationMutation) ClearRoleID() { + m.role_id = nil + m.addrole_id = nil + m.clearedFields[invitation.FieldRoleID] = struct{}{} +} + +// RoleIDCleared returns if the "role_id" field was cleared in this mutation. +func (m *InvitationMutation) RoleIDCleared() bool { + _, ok := m.clearedFields[invitation.FieldRoleID] + return ok +} + +// ResetRoleID resets all changes to the "role_id" field. +func (m *InvitationMutation) ResetRoleID() { + m.role_id = nil + m.addrole_id = nil + delete(m.clearedFields, invitation.FieldRoleID) +} + // SetExpiresAt sets the "expires_at" field. func (m *InvitationMutation) SetExpiresAt(t time.Time) { m.expires_at = &t @@ -9483,7 +9555,7 @@ func (m *InvitationMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *InvitationMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 9) if m.created_at != nil { fields = append(fields, invitation.FieldCreatedAt) } @@ -9499,6 +9571,9 @@ func (m *InvitationMutation) Fields() []string { if m.project != nil { fields = append(fields, invitation.FieldProjectID) } + if m.role_id != nil { + fields = append(fields, invitation.FieldRoleID) + } if m.expires_at != nil { fields = append(fields, invitation.FieldExpiresAt) } @@ -9526,6 +9601,8 @@ func (m *InvitationMutation) Field(name string) (ent.Value, bool) { return m.TokenHash() case invitation.FieldProjectID: return m.ProjectID() + case invitation.FieldRoleID: + return m.RoleID() case invitation.FieldExpiresAt: return m.ExpiresAt() case invitation.FieldMaxUses: @@ -9551,6 +9628,8 @@ func (m *InvitationMutation) OldField(ctx context.Context, name string) (ent.Val return m.OldTokenHash(ctx) case invitation.FieldProjectID: return m.OldProjectID(ctx) + case invitation.FieldRoleID: + return m.OldRoleID(ctx) case invitation.FieldExpiresAt: return m.OldExpiresAt(ctx) case invitation.FieldMaxUses: @@ -9601,6 +9680,13 @@ func (m *InvitationMutation) SetField(name string, value ent.Value) error { } m.SetProjectID(v) return nil + case invitation.FieldRoleID: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetRoleID(v) + return nil case invitation.FieldExpiresAt: v, ok := value.(time.Time) if !ok { @@ -9633,6 +9719,9 @@ func (m *InvitationMutation) AddedFields() []string { if m.adddeleted_at != nil { fields = append(fields, invitation.FieldDeletedAt) } + if m.addrole_id != nil { + fields = append(fields, invitation.FieldRoleID) + } if m.addmax_uses != nil { fields = append(fields, invitation.FieldMaxUses) } @@ -9649,6 +9738,8 @@ func (m *InvitationMutation) AddedField(name string) (ent.Value, bool) { switch name { case invitation.FieldDeletedAt: return m.AddedDeletedAt() + case invitation.FieldRoleID: + return m.AddedRoleID() case invitation.FieldMaxUses: return m.AddedMaxUses() case invitation.FieldUsedCount: @@ -9669,6 +9760,13 @@ func (m *InvitationMutation) AddField(name string, value ent.Value) error { } m.AddDeletedAt(v) return nil + case invitation.FieldRoleID: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddRoleID(v) + return nil case invitation.FieldMaxUses: v, ok := value.(int) if !ok { @@ -9691,6 +9789,9 @@ func (m *InvitationMutation) AddField(name string, value ent.Value) error { // mutation. func (m *InvitationMutation) ClearedFields() []string { var fields []string + if m.FieldCleared(invitation.FieldRoleID) { + fields = append(fields, invitation.FieldRoleID) + } if m.FieldCleared(invitation.FieldExpiresAt) { fields = append(fields, invitation.FieldExpiresAt) } @@ -9708,6 +9809,9 @@ func (m *InvitationMutation) FieldCleared(name string) bool { // error if the field is not defined in the schema. func (m *InvitationMutation) ClearField(name string) error { switch name { + case invitation.FieldRoleID: + m.ClearRoleID() + return nil case invitation.FieldExpiresAt: m.ClearExpiresAt() return nil @@ -9734,6 +9838,9 @@ func (m *InvitationMutation) ResetField(name string) error { case invitation.FieldProjectID: m.ResetProjectID() return nil + case invitation.FieldRoleID: + m.ResetRoleID() + return nil case invitation.FieldExpiresAt: m.ResetExpiresAt() return nil diff --git a/internal/ent/runtime/runtime.go b/internal/ent/runtime/runtime.go index dd5549a9b..b7ef34162 100644 --- a/internal/ent/runtime/runtime.go +++ b/internal/ent/runtime/runtime.go @@ -395,11 +395,11 @@ func init() { // invitation.DefaultDeletedAt holds the default value on creation for the deleted_at field. invitation.DefaultDeletedAt = invitationDescDeletedAt.Default.(int) // invitationDescMaxUses is the schema descriptor for max_uses field. - invitationDescMaxUses := invitationFields[3].Descriptor() + invitationDescMaxUses := invitationFields[4].Descriptor() // invitation.DefaultMaxUses holds the default value on creation for the max_uses field. invitation.DefaultMaxUses = invitationDescMaxUses.Default.(int) // invitationDescUsedCount is the schema descriptor for used_count field. - invitationDescUsedCount := invitationFields[4].Descriptor() + invitationDescUsedCount := invitationFields[5].Descriptor() // invitation.DefaultUsedCount holds the default value on creation for the used_count field. invitation.DefaultUsedCount = invitationDescUsedCount.Default.(int) modelMixin := schema.Model{}.Mixin() diff --git a/internal/ent/schema/invitation.go b/internal/ent/schema/invitation.go index b509a612f..dd372599d 100644 --- a/internal/ent/schema/invitation.go +++ b/internal/ent/schema/invitation.go @@ -32,6 +32,7 @@ func (Invitation) Fields() []ent.Field { return []ent.Field{ field.String("token_hash").Sensitive(), field.Int("project_id"), + field.Int("role_id").Optional().Nillable(), field.Time("expires_at").Optional().Nillable(), field.Int("max_uses").Default(1), field.Int("used_count").Default(0), diff --git a/internal/objects/user.go b/internal/objects/user.go index bb0b36a5f..411421cdd 100644 --- a/internal/objects/user.go +++ b/internal/objects/user.go @@ -1,5 +1,6 @@ package objects +// UserInfo contains the authenticated user's profile, permissions, and projects. type UserInfo struct { ID GUID `json:"id"` Email string `json:"email"` @@ -15,6 +16,7 @@ type UserInfo struct { HasPassword bool `json:"hasPassword"` } +// OIDCIdentityInfo contains an identity provider association. type OIDCIdentityInfo struct { ID GUID `json:"id"` IdpName string `json:"idpName"` @@ -23,13 +25,16 @@ type OIDCIdentityInfo struct { Email string `json:"email"` } +// UserProjectInfo contains a user's membership and effective permissions for a project. type UserProjectInfo struct { - ProjectID GUID `json:"projectID"` - IsOwner bool `json:"isOwner"` - Scopes []string `json:"scopes"` - Roles []RoleInfo `json:"roles"` + ProjectID GUID `json:"projectID"` + IsOwner bool `json:"isOwner"` + Scopes []string `json:"scopes"` + EffectiveScopes []string `json:"effectiveScopes"` + Roles []RoleInfo `json:"roles"` } +// RoleInfo contains the display name of a role. type RoleInfo struct { Name string `json:"name"` } diff --git a/internal/server/api/invitation.go b/internal/server/api/invitation.go index 7c0968bcc..cb39bc3b2 100644 --- a/internal/server/api/invitation.go +++ b/internal/server/api/invitation.go @@ -13,6 +13,7 @@ import ( "github.com/looplj/axonhub/internal/server/biz" ) +// InvitationHandlersParams contains dependencies for invitation HTTP handlers. type InvitationHandlersParams struct { fx.In @@ -20,11 +21,13 @@ type InvitationHandlersParams struct { AuthService *biz.AuthService } +// InvitationHandlers serves invitation creation, lookup, and registration endpoints. type InvitationHandlers struct { InvitationService *biz.InvitationService AuthService *biz.AuthService } +// NewInvitationHandlers creates invitation HTTP handlers. func NewInvitationHandlers(params InvitationHandlersParams) *InvitationHandlers { return &InvitationHandlers{ InvitationService: params.InvitationService, @@ -32,11 +35,14 @@ func NewInvitationHandlers(params InvitationHandlersParams) *InvitationHandlers } } +// CreateInvitationRequest contains the parameters for creating an invitation. type CreateInvitationRequest struct { ExpiresInHours *int `json:"expiresInHours"` MaxUses int `json:"maxUses"` + RoleID int `json:"roleID" binding:"required"` } +// InvitationResponse contains invitation metadata returned by the API. type InvitationResponse struct { Token string `json:"token,omitempty"` ProjectName string `json:"projectName"` @@ -46,6 +52,7 @@ type InvitationResponse struct { RemainingUses int `json:"remainingUses"` } +// RegisterInvitationRequest contains the credentials and profile data for registration. type RegisterInvitationRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=7"` @@ -53,11 +60,13 @@ type RegisterInvitationRequest struct { LastName string `json:"lastName"` } +// RegisterInvitationResponse contains the registered user and session token. type RegisterInvitationResponse struct { User *objects.UserInfo `json:"user"` Token string `json:"token"` } +// Create handles invitation creation for a project. func (h *InvitationHandlers) Create(c *gin.Context) { projectID, ok := contexts.GetProjectID(c.Request.Context()) if !ok { @@ -71,7 +80,7 @@ func (h *InvitationHandlers) Create(c *gin.Context) { return } - created, err := h.InvitationService.CreateInvitation(c.Request.Context(), projectID, req.ExpiresInHours, req.MaxUses) + created, err := h.InvitationService.CreateInvitation(c.Request.Context(), projectID, req.RoleID, req.ExpiresInHours, req.MaxUses) if err != nil { JSONError(c, http.StatusForbidden, err) return @@ -80,6 +89,7 @@ func (h *InvitationHandlers) Create(c *gin.Context) { c.JSON(http.StatusOK, invitationResponse(created.Token, created.Info)) } +// Get handles public invitation metadata lookup. func (h *InvitationHandlers) Get(c *gin.Context) { info, err := h.InvitationService.GetInvitation(c.Request.Context(), c.Param("token")) if err != nil { @@ -90,6 +100,7 @@ func (h *InvitationHandlers) Get(c *gin.Context) { c.JSON(http.StatusOK, invitationResponse("", *info)) } +// Register handles account registration through an invitation. func (h *InvitationHandlers) Register(c *gin.Context) { var req RegisterInvitationRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/server/biz/api_key.go b/internal/server/biz/api_key.go index 8eef9b3e7..7f555dcf3 100644 --- a/internal/server/biz/api_key.go +++ b/internal/server/biz/api_key.go @@ -321,6 +321,11 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, input ent.CreateAPIKey apiKeyType = *input.Type } + if apiKeyType == apikey.TypeUser { + if err := s.requireProjectAdmin(ctx, user.ID, input.ProjectID); err != nil { + return nil, err + } + } // Generate API key with configured prefix generatedKey, err := GenerateAPIKey(s.keyPrefix) @@ -400,6 +405,29 @@ func (s *APIKeyService) CreateAPIKey(ctx context.Context, input ent.CreateAPIKey return apiKey, nil } +func (s *APIKeyService) requireProjectAdmin(ctx context.Context, userID, projectID int) error { + currentUser, err := authz.RunWithSystemBypass(ctx, "api-key-project-permission", func(bypassCtx context.Context) (*ent.User, error) { + return s.entFromContext(bypassCtx).User.Query(). + Where(user.IDEQ(userID)). + WithRoles(). + WithProjectUsers(). + Only(bypassCtx) + }) + if err != nil { + return fmt.Errorf("failed to load API key creator permissions: %w", err) + } + + projectCtx := contexts.WithUser(ctx, currentUser) + if err := NewPermissionValidator().CanGrantScopes(projectCtx, []string{ + string(scopes.ScopeWriteUsers), + string(scopes.ScopeWriteRoles), + }, &projectID); err != nil { + return fmt.Errorf("permission denied: project API keys require project admin permissions") + } + + return nil +} + // UpdateAPIKey updates an existing API key. func (s *APIKeyService) UpdateAPIKey(ctx context.Context, id int, input ent.UpdateAPIKeyInput) (*ent.APIKey, error) { var result *ent.APIKey diff --git a/internal/server/biz/api_key_test.go b/internal/server/biz/api_key_test.go index d27f253a6..77dcdecdb 100644 --- a/internal/server/biz/api_key_test.go +++ b/internal/server/biz/api_key_test.go @@ -16,6 +16,7 @@ import ( "github.com/looplj/axonhub/internal/ent/apikey" "github.com/looplj/axonhub/internal/ent/enttest" "github.com/looplj/axonhub/internal/ent/project" + "github.com/looplj/axonhub/internal/ent/role" "github.com/looplj/axonhub/internal/ent/user" "github.com/looplj/axonhub/internal/objects" "github.com/looplj/axonhub/internal/pkg/xcache" @@ -918,6 +919,40 @@ func TestAPIKeyService_CreateAPIKey_Type(t *testing.T) { require.Len(t, apiKey.Scopes, 2) }) + t.Run("non-admin cannot create project type API key", func(t *testing.T) { + developerRole, err := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(testProject.ID). + SetScopes([]string{"write_api_keys"}). + Save(ctx) + require.NoError(t, err) + nonAdmin, err := client.User.Create(). + SetEmail(fmt.Sprintf("developer-%d@example.com", time.Now().UnixNano())). + SetPassword("password"). + SetStatus(user.StatusActivated). + Save(ctx) + require.NoError(t, err) + _, err = client.UserProject.Create().SetUserID(nonAdmin.ID).SetProjectID(testProject.ID).Save(ctx) + require.NoError(t, err) + _, err = client.UserRole.Create().SetUserID(nonAdmin.ID).SetRoleID(developerRole.ID).Save(ctx) + require.NoError(t, err) + + projectType := apikey.TypeUser + _, err = apiKeyService.CreateAPIKey(contexts.WithUser(ctx, nonAdmin), ent.CreateAPIKeyInput{ + Name: "Developer Project API Key", + ProjectID: testProject.ID, + Type: &projectType, + }) + require.ErrorContains(t, err, "project API keys require project admin permissions") + + _, err = apiKeyService.CreateAPIKey(contexts.WithUser(ctx, nonAdmin), ent.CreateAPIKeyInput{ + Name: "Developer Default API Key", + ProjectID: testProject.ID, + }) + require.ErrorContains(t, err, "project API keys require project admin permissions") + }) + t.Run("Create service_account type API key without scopes", func(t *testing.T) { serviceAccountType := apikey.TypeServiceAccount apiKey, err := apiKeyService.CreateAPIKey(ctxWithUser, ent.CreateAPIKeyInput{ @@ -1231,6 +1266,12 @@ func TestAPIKeyService_RotateAPIKey(t *testing.T) { SetStatus(project.StatusActive). Save(setupCtx) require.NoError(t, err) + _, err = client.UserProject.Create(). + SetUserID(ownerUser.ID). + SetProjectID(testProject.ID). + SetIsOwner(true). + Save(setupCtx) + require.NoError(t, err) ctxWithUser := contexts.WithUser(setupCtx, ownerUser) diff --git a/internal/server/biz/invitation.go b/internal/server/biz/invitation.go index b3dce552e..b07ea1cb0 100644 --- a/internal/server/biz/invitation.go +++ b/internal/server/biz/invitation.go @@ -18,26 +18,36 @@ import ( "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/invitation" "github.com/looplj/axonhub/internal/ent/project" + "github.com/looplj/axonhub/internal/ent/role" "github.com/looplj/axonhub/internal/ent/user" "github.com/looplj/axonhub/internal/scopes" ) const defaultInvitationLifetime = 7 * 24 * time.Hour +// InvitationServiceParams contains dependencies for the invitation service. type InvitationServiceParams struct { fx.In Ent *ent.Client } +// InvitationService manages project invitations and invitation registration. type InvitationService struct { *AbstractService + + permissionValidator *PermissionValidator } +// NewInvitationService creates an invitation service. func NewInvitationService(params InvitationServiceParams) *InvitationService { - return &InvitationService{AbstractService: &AbstractService{db: params.Ent}} + return &InvitationService{ + AbstractService: &AbstractService{db: params.Ent}, + permissionValidator: NewPermissionValidator(), + } } +// InvitationInfo describes the current state of an invitation. type InvitationInfo struct { ProjectName string ExpiresAt *time.Time @@ -46,15 +56,20 @@ type InvitationInfo struct { RemainingUses int } +// CreatedInvitation contains a newly generated invitation token and metadata. type CreatedInvitation struct { Token string Info InvitationInfo } -func (s *InvitationService) CreateInvitation(ctx context.Context, projectID int, expiresInHours *int, maxUses int) (*CreatedInvitation, error) { +// CreateInvitation creates a role-bound invitation for a project. +func (s *InvitationService) CreateInvitation(ctx context.Context, projectID, roleID int, expiresInHours *int, maxUses int) (*CreatedInvitation, error) { if err := s.canInvite(ctx, projectID); err != nil { return nil, err } + if roleID == 0 { + return nil, fmt.Errorf("project role is required") + } if maxUses != 0 && maxUses != 1 { return nil, fmt.Errorf("max uses must be 1 or 0") @@ -77,10 +92,23 @@ func (s *InvitationService) CreateInvitation(ctx context.Context, projectID int, return nil, fmt.Errorf("failed to get project: %w", err) } + projectRole, err := authz.RunWithSystemBypass(ctx, "invitation-role-lookup", func(ctx context.Context) (*ent.Role, error) { + return s.entFromContext(ctx).Role.Query(). + Where(role.IDEQ(roleID), role.ProjectIDEQ(projectID)). + Only(ctx) + }) + if err != nil { + return nil, fmt.Errorf("project role not found") + } + if err := s.permissionValidator.CanGrantRole(ctx, projectRole.Scopes, &projectID); err != nil { + return nil, fmt.Errorf("permission denied: %w", err) + } + created, err := authz.RunWithSystemBypass(ctx, "invitation-create", func(ctx context.Context) (*ent.Invitation, error) { return s.entFromContext(ctx).Invitation.Create(). SetTokenHash(hashInvitationToken(token)). SetProjectID(projectID). + SetRoleID(roleID). SetNillableExpiresAt(expiresAt). SetMaxUses(maxUses). Save(ctx) @@ -95,6 +123,7 @@ func (s *InvitationService) CreateInvitation(ctx context.Context, projectID int, }, nil } +// GetInvitation returns valid invitation metadata for a token. func (s *InvitationService) GetInvitation(ctx context.Context, token string) (*InvitationInfo, error) { row, err := authz.RunWithSystemBypass(ctx, "invitation-read", func(ctx context.Context) (*ent.Invitation, error) { return s.entFromContext(ctx).Invitation.Query(). @@ -114,6 +143,7 @@ func (s *InvitationService) GetInvitation(ctx context.Context, token string) (*I return &info, nil } +// RegisterInvitation creates a user and assigns the invitation's project role. func (s *InvitationService) RegisterInvitation(ctx context.Context, token, email, password, firstName, lastName string) (*ent.User, error) { email = strings.TrimSpace(email) if email == "" || password == "" { @@ -136,6 +166,16 @@ func (s *InvitationService) RegisterInvitation(ctx context.Context, token, email if invitationIsExpired(invitationRow, time.Now()) || (invitationRow.MaxUses > 0 && invitationRow.UsedCount >= invitationRow.MaxUses) { return fmt.Errorf("invitation is no longer valid") } + roleID := invitationRow.RoleID + if roleID == nil { + return fmt.Errorf("invitation role is required") + } + if _, err := client.Role.Query().Where( + role.IDEQ(*roleID), + role.ProjectIDEQ(invitationRow.ProjectID), + ).Only(ctx); err != nil { + return fmt.Errorf("invitation role is no longer available") + } exists, err := client.User.Query().Where(user.EmailEQ(email)).Exist(ctx) if err != nil { return fmt.Errorf("failed to check existing user: %w", err) @@ -198,6 +238,9 @@ func (s *InvitationService) RegisterInvitation(ctx context.Context, token, email if _, err := client.UserProject.Create().SetUserID(createdUser.ID).SetProjectID(invitationRow.ProjectID).Save(ctx); err != nil { return fmt.Errorf("failed to add user to project: %w", err) } + if err := client.User.UpdateOneID(createdUser.ID).AddRoleIDs(*roleID).Exec(ctx); err != nil { + return fmt.Errorf("failed to assign invitation role: %w", err) + } return nil }) diff --git a/internal/server/biz/invitation_test.go b/internal/server/biz/invitation_test.go index 4457c1729..331e9aa34 100644 --- a/internal/server/biz/invitation_test.go +++ b/internal/server/biz/invitation_test.go @@ -12,6 +12,8 @@ import ( "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/ent/user" "github.com/looplj/axonhub/internal/ent/userproject" ) @@ -20,11 +22,27 @@ func setupInvitationService(t *testing.T) (*InvitationService, *ent.Client, cont client := enttest.NewEntClient(t, "sqlite3", "file:invitation?mode=memory&_fk=1") ctx := authz.WithTestBypass(ent.NewContext(context.Background(), client)) - service := &InvitationService{AbstractService: &AbstractService{db: client}} + service := &InvitationService{ + AbstractService: &AbstractService{db: client}, + permissionValidator: NewPermissionValidator(), + } return service, client, ctx } +func createInvitationRole(t *testing.T, client *ent.Client, ctx context.Context, projectID int) *ent.Role { + t.Helper() + + projectRole, err := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(projectID). + SetScopes([]string{"write_requests"}). + Save(ctx) + require.NoError(t, err) + return projectRole +} + func TestInvitationService_SingleUseInvitation(t *testing.T) { service, client, ctx := setupInvitationService(t) defer client.Close() @@ -33,8 +51,9 @@ func TestInvitationService_SingleUseInvitation(t *testing.T) { require.NoError(t, err) project, err := client.Project.Create().SetName("single-use-project").Save(ctx) require.NoError(t, err) + projectRole := createInvitationRole(t, client, ctx, project.ID) - created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, nil, 1) + created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, projectRole.ID, nil, 1) require.NoError(t, err) registered, err := service.RegisterInvitation(ctx, created.Token, "first@example.com", "password", "First", "Member") @@ -50,6 +69,13 @@ func TestInvitationService_SingleUseInvitation(t *testing.T) { ).Exist(ctx) require.NoError(t, err) require.True(t, exists) + hasRole, err := registered.QueryRoles().Where(role.IDEQ(projectRole.ID)).Exist(ctx) + require.NoError(t, err) + require.True(t, hasRole) + + userInfo := ConvertUserToUserInfo(ctx, registered) + require.Len(t, userInfo.Projects, 1) + require.Contains(t, userInfo.Projects[0].EffectiveScopes, "write_requests") } func TestInvitationService_UnlimitedInvitation(t *testing.T) { @@ -60,8 +86,9 @@ func TestInvitationService_UnlimitedInvitation(t *testing.T) { require.NoError(t, err) project, err := client.Project.Create().SetName("unlimited-project").Save(ctx) require.NoError(t, err) + projectRole := createInvitationRole(t, client, ctx, project.ID) neverExpires := 0 - created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, &neverExpires, 0) + created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, projectRole.ID, &neverExpires, 0) require.NoError(t, err) first, err := service.RegisterInvitation(ctx, created.Token, "first@example.com", "password", "First", "Member") @@ -76,6 +103,58 @@ func TestInvitationService_UnlimitedInvitation(t *testing.T) { require.NotEqual(t, first.ID, second.ID) } +func TestInvitationService_RejectsUnmigratedLegacyInvitation(t *testing.T) { + service, client, ctx := setupInvitationService(t) + defer client.Close() + + project, err := client.Project.Create().SetName("legacy-project").Save(ctx) + require.NoError(t, err) + token := "legacy-token" + _, err = client.Invitation.Create(). + SetTokenHash(hashInvitationToken(token)). + SetProjectID(project.ID). + Save(ctx) + require.NoError(t, err) + + _, err = service.RegisterInvitation(ctx, token, "legacy@example.com", "password", "Legacy", "Member") + require.ErrorContains(t, err, "invitation role is required") +} + +func TestInvitationService_RejectsRoleTheInviterCannotGrant(t *testing.T) { + service, client, ctx := setupInvitationService(t) + defer client.Close() + + project, err := client.Project.Create().SetName("restricted-project").Save(ctx) + require.NoError(t, err) + projectRole := createInvitationRole(t, client, ctx, project.ID) + inviter, err := client.User.Create().SetEmail("inviter@example.com").SetPassword("password").Save(ctx) + require.NoError(t, err) + _, err = client.UserProject.Create().SetUserID(inviter.ID).SetProjectID(project.ID).SetScopes([]string{"write_users"}).Save(ctx) + require.NoError(t, err) + inviter, err = client.User.Query().Where(user.IDEQ(inviter.ID)).WithProjectUsers().WithRoles().Only(ctx) + require.NoError(t, err) + + _, err = service.CreateInvitation(contexts.WithUser(ctx, inviter), project.ID, projectRole.ID, nil, 1) + require.ErrorContains(t, err, "permission denied") +} + +func TestInvitationService_RejectsDeletedInvitationRole(t *testing.T) { + service, client, ctx := setupInvitationService(t) + defer client.Close() + + owner, err := client.User.Create().SetEmail("owner@example.com").SetPassword("password").SetIsOwner(true).Save(ctx) + require.NoError(t, err) + project, err := client.Project.Create().SetName("deleted-role-project").Save(ctx) + require.NoError(t, err) + projectRole := createInvitationRole(t, client, ctx, project.ID) + created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, projectRole.ID, nil, 1) + require.NoError(t, err) + require.NoError(t, client.Role.DeleteOneID(projectRole.ID).Exec(ctx)) + + _, err = service.RegisterInvitation(ctx, created.Token, "member@example.com", "password", "Member", "User") + require.ErrorContains(t, err, "invitation role is no longer available") +} + func TestInvitationService_ExpiredInvitation(t *testing.T) { service, client, ctx := setupInvitationService(t) defer client.Close() @@ -84,8 +163,9 @@ func TestInvitationService_ExpiredInvitation(t *testing.T) { require.NoError(t, err) project, err := client.Project.Create().SetName("expired-project").Save(ctx) require.NoError(t, err) + projectRole := createInvitationRole(t, client, ctx, project.ID) oneHour := 1 - created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, &oneHour, 1) + created, err := service.CreateInvitation(contexts.WithUser(ctx, owner), project.ID, projectRole.ID, &oneHour, 1) require.NoError(t, err) invitation, err := client.Invitation.Query().Only(ctx) diff --git a/internal/server/biz/permission_validator.go b/internal/server/biz/permission_validator.go index 70479db46..0a6395c14 100644 --- a/internal/server/biz/permission_validator.go +++ b/internal/server/biz/permission_validator.go @@ -222,6 +222,9 @@ func (v *PermissionValidator) CanEditRole(ctx context.Context, roleID int, proje if err != nil { return fmt.Errorf("failed to get role: %w", err) } + if projectID == nil && !role.IsSystemRole() { + projectID = role.ProjectID + } return v.CanGrantRole(ctx, role.Scopes, projectID) } diff --git a/internal/server/biz/permission_validator_test.go b/internal/server/biz/permission_validator_test.go index b7abc1ad1..bbbe8ea1d 100644 --- a/internal/server/biz/permission_validator_test.go +++ b/internal/server/biz/permission_validator_test.go @@ -351,6 +351,39 @@ func TestCanEditRole(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "insufficient permissions") }) + + t.Run("project member can edit a project role with its effective scopes", func(t *testing.T) { + project, err := client.Project.Create().SetName("role-permission-project").Save(ctx) + require.NoError(t, err) + managedRole, err := client.Role.Create(). + SetName("Managed Role"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"read_prompts"}). + Save(ctx) + require.NoError(t, err) + userRole, err := client.Role.Create(). + SetName("Project Administrator"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"read_prompts", "write_roles"}). + Save(ctx) + require.NoError(t, err) + projectAdmin, err := client.User.Create(). + SetEmail("project-admin@example.com"). + SetFirstName("Project"). + SetLastName("Admin"). + SetPassword("password"). + AddRoles(userRole). + Save(ctx) + require.NoError(t, err) + _, err = client.UserProject.Create().SetUserID(projectAdmin.ID).SetProjectID(project.ID).Save(ctx) + require.NoError(t, err) + + projectAdmin, err = userService.GetUserByID(ctx, projectAdmin.ID) + require.NoError(t, err) + require.NoError(t, validator.CanEditRole(contexts.WithUser(ctx, projectAdmin), managedRole.ID, nil)) + }) } func TestProjectLevelPermissions(t *testing.T) { diff --git a/internal/server/biz/project.go b/internal/server/biz/project.go index 72d4fe8d1..0965bca7a 100644 --- a/internal/server/biz/project.go +++ b/internal/server/biz/project.go @@ -83,8 +83,8 @@ func (s *ProjectService) CreateProject(ctx context.Context, input ent.CreateProj return nil, fmt.Errorf("failed to create project: %w", err) } - // Create three default project-level roles - // Admin role - full permissions + // Create project-level role presets. System infrastructure remains system-scoped. + // Project Admin role - full project management permissions. adminScopes := []string{ string(scopes.ScopeReadUsers), string(scopes.ScopeWriteUsers), @@ -94,6 +94,8 @@ func (s *ProjectService) CreateProject(ctx context.Context, input ent.CreateProj string(scopes.ScopeWriteAPIKeys), string(scopes.ScopeReadRequests), string(scopes.ScopeWriteRequests), + string(scopes.ScopeReadPrompts), + string(scopes.ScopeWritePrompts), } _, err = client.Role.Create(). @@ -106,12 +108,12 @@ func (s *ProjectService) CreateProject(ctx context.Context, input ent.CreateProj return nil, fmt.Errorf("failed to create admin role: %w", err) } - // Developer role - read/write channels, read users, read requests + // Developer role - use the project without gaining global Channel access. developerScopes := []string{ - string(scopes.ScopeReadUsers), string(scopes.ScopeReadAPIKeys), string(scopes.ScopeWriteAPIKeys), string(scopes.ScopeReadRequests), + string(scopes.ScopeWriteRequests), } _, err = client.Role.Create(). @@ -124,9 +126,9 @@ func (s *ProjectService) CreateProject(ctx context.Context, input ent.CreateProj return nil, fmt.Errorf("failed to create developer role: %w", err) } - // Viewer role - read-only permissions + // Viewer role - inspect project content without changing it. viewerScopes := []string{ - string(scopes.ScopeReadUsers), + string(scopes.ScopeReadPrompts), string(scopes.ScopeReadRequests), } diff --git a/internal/server/biz/role.go b/internal/server/biz/role.go index 063a761f0..dd7888bd4 100644 --- a/internal/server/biz/role.go +++ b/internal/server/biz/role.go @@ -8,7 +8,9 @@ import ( "github.com/samber/lo" "go.uber.org/fx" + "github.com/looplj/axonhub/internal/authz" "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/invitation" "github.com/looplj/axonhub/internal/ent/role" "github.com/looplj/axonhub/internal/ent/userrole" "github.com/looplj/axonhub/internal/pkg/xerrors" @@ -177,34 +179,35 @@ func (s *RoleService) UpdateRole(ctx context.Context, id int, input ent.UpdateRo // DeleteRole deletes a role and all associated user-role relationships. // It uses the UserRole entity to delete all relationships through the role_id. func (s *RoleService) DeleteRole(ctx context.Context, id int) error { - client := s.entFromContext(ctx) + err := s.RunInTransaction(ctx, func(ctx context.Context) error { + client := s.entFromContext(ctx) - // First, check if the role exists - exists, err := client.Role.Query(). - Where(role.IDEQ(id)). - Exist(ctx) - if err != nil { - return fmt.Errorf("failed to check role existence: %w", err) - } + roleEntity, err := client.Role.Query(). + Where(role.IDEQ(id)). + Only(ctx) + if err != nil { + return fmt.Errorf("failed to check role existence: %w", err) + } + if roleEntity.Name == "Developer" { + return fmt.Errorf("cannot delete the default Developer role") + } - if !exists { - return fmt.Errorf("role not found") - } + if err := revokeRoleInvitations(ctx, id); err != nil { + return err + } + if _, err := client.UserRole.Delete().Where(userrole.RoleID(id)).Exec(ctx); err != nil { + return fmt.Errorf("failed to delete user role relationships: %w", err) + } + if err := client.Role.DeleteOneID(id).Exec(ctx); err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } - // Delete all UserRole relationships for this role - _, err = client.UserRole.Delete(). - Where(userrole.RoleID(id)). - Exec(ctx) + return nil + }) if err != nil { - return fmt.Errorf("failed to delete user role relationships: %w", err) + return err } - // Now delete the role itself - err = client.Role.DeleteOneID(id).Exec(ctx) - if err != nil { - return fmt.Errorf("failed to delete role: %w", err) - } - // Invalidate cache for all users with this role BEFORE deleting relationships s.invalidateUserCache(ctx) return nil @@ -212,42 +215,54 @@ func (s *RoleService) DeleteRole(ctx context.Context, id int) error { // BulkDeleteRoles deletes multiple roles and all associated user-role relationships. func (s *RoleService) BulkDeleteRoles(ctx context.Context, ids []int) error { - client := s.entFromContext(ctx) - if len(ids) == 0 { return nil } - // Verify all roles exist - count, err := client.Role.Query(). - Where(role.IDIn(ids...)). - Count(ctx) - if err != nil { - return fmt.Errorf("failed to query roles: %w", err) - } + err := s.RunInTransaction(ctx, func(ctx context.Context) error { + client := s.entFromContext(ctx) + roles, err := client.Role.Query().Where(role.IDIn(ids...)).All(ctx) + if err != nil { + return fmt.Errorf("failed to query roles: %w", err) + } + if len(roles) != len(ids) { + return fmt.Errorf("expected to find %d roles, but found %d", len(ids), len(roles)) + } + for _, r := range roles { + if r.Name == "Developer" { + return fmt.Errorf("cannot delete the default Developer role") + } + } - if count != len(ids) { - return fmt.Errorf("expected to find %d roles, but found %d", len(ids), count) - } + if err := revokeRoleInvitations(ctx, ids...); err != nil { + return err + } + if _, err := client.UserRole.Delete().Where(userrole.RoleIDIn(ids...)).Exec(ctx); err != nil { + return fmt.Errorf("failed to delete user role relationships: %w", err) + } + if _, err := client.Role.Delete().Where(role.IDIn(ids...)).Exec(ctx); err != nil { + return fmt.Errorf("failed to delete roles: %w", err) + } - // Delete all UserRole relationships for these roles - _, err = client.UserRole.Delete(). - Where(userrole.RoleIDIn(ids...)). - Exec(ctx) + return nil + }) if err != nil { - return fmt.Errorf("failed to delete user role relationships: %w", err) + return err } - // Now delete all roles - _, err = client.Role.Delete(). - Where(role.IDIn(ids...)). - Exec(ctx) + s.invalidateUserCache(ctx) + + return nil +} + +func revokeRoleInvitations(ctx context.Context, roleIDs ...int) error { + _, err := authz.RunWithSystemBypass(ctx, "revoke-role-invitations", func(ctx context.Context) (int, error) { + return ent.FromContext(ctx).Invitation.Delete().Where(invitation.RoleIDIn(roleIDs...)).Exec(ctx) + }) if err != nil { - return fmt.Errorf("failed to delete roles: %w", err) + return fmt.Errorf("failed to revoke role invitations: %w", err) } - s.invalidateUserCache(ctx) - return nil } diff --git a/internal/server/biz/role_test.go b/internal/server/biz/role_test.go index 6a729c699..30cf5e461 100644 --- a/internal/server/biz/role_test.go +++ b/internal/server/biz/role_test.go @@ -11,6 +11,7 @@ import ( "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/invitation" "github.com/looplj/axonhub/internal/ent/role" "github.com/looplj/axonhub/internal/ent/user" "github.com/looplj/axonhub/internal/ent/userrole" @@ -28,6 +29,7 @@ func setupTestRoleService(t *testing.T) (*RoleService, *UserService, *ent.Client } roleService := &RoleService{ + AbstractService: &AbstractService{db: client}, userService: userService, permissionValidator: NewPermissionValidator(), } @@ -279,6 +281,50 @@ func TestDeleteRole(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "role not found") }) + + t.Run("fail to delete Developer role", func(t *testing.T) { + // Create a Developer role + developerRole, err := client.Role.Create(). + SetName("Developer"). + SetScopes([]string{"read", "write"}). + Save(ctx) + require.NoError(t, err) + + // Try to delete the Developer role + err = roleService.DeleteRole(ctx, developerRole.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot delete the default Developer role") + + // Verify role still exists + exists, err := client.Role.Query(). + Where(role.IDEQ(developerRole.ID)). + Exist(ctx) + require.NoError(t, err) + require.True(t, exists) + }) + + t.Run("revokes invitations bound to the deleted role", func(t *testing.T) { + project, err := client.Project.Create().SetName("invitation-role-project").Save(ctx) + require.NoError(t, err) + projectRole, err := client.Role.Create(). + SetName("Invitation Role"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"read_prompts"}). + Save(ctx) + require.NoError(t, err) + createdInvitation, err := client.Invitation.Create(). + SetTokenHash("invitation-role-token"). + SetProjectID(project.ID). + SetRoleID(projectRole.ID). + Save(ctx) + require.NoError(t, err) + + require.NoError(t, roleService.DeleteRole(ctx, projectRole.ID)) + exists, err := client.Invitation.Query().Where(invitation.IDEQ(createdInvitation.ID)).Exist(ctx) + require.NoError(t, err) + require.False(t, exists) + }) } func TestBulkDeleteRoles(t *testing.T) { @@ -430,6 +476,34 @@ func TestBulkDeleteRoles(t *testing.T) { err := roleService.BulkDeleteRoles(ctx, []int{}) require.NoError(t, err) }) + + t.Run("fail to bulk delete Developer role", func(t *testing.T) { + // Create a Developer role and a regular role + developerRole, err := client.Role.Create(). + SetName("Developer"). + SetScopes([]string{"read", "write"}). + Save(ctx) + require.NoError(t, err) + + regularRole, err := client.Role.Create(). + SetName("Regular Role"). + SetScopes([]string{"read"}). + Save(ctx) + require.NoError(t, err) + + // Try to bulk delete both roles + roleIDs := []int{developerRole.ID, regularRole.ID} + err = roleService.BulkDeleteRoles(ctx, roleIDs) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot delete the default Developer role") + + // Verify both roles still exist + exists, err := client.Role.Query(). + Where(role.IDIn(roleIDs...)). + Exist(ctx) + require.NoError(t, err) + require.True(t, exists) + }) } func TestUpdateRole_CacheInvalidation(t *testing.T) { diff --git a/internal/server/biz/user.go b/internal/server/biz/user.go index 702d5718b..03bf93b04 100644 --- a/internal/server/biz/user.go +++ b/internal/server/biz/user.go @@ -319,19 +319,27 @@ func ConvertUserToUserInfo(ctx context.Context, u *ent.User) *objects.UserInfo { for _, up := range u.Edges.ProjectUsers { // Convert project roles to objects.RoleInfo roles := projectRoles[up.ProjectID] + effectiveScopeSet := make(map[string]bool, len(up.Scopes)) + for _, scope := range up.Scopes { + effectiveScopeSet[scope] = true + } projectRoleInfos := make([]objects.RoleInfo, 0, len(roles)) for _, r := range roles { projectRoleInfos = append(projectRoleInfos, objects.RoleInfo{ Name: r.Name, }) + for _, scope := range r.Scopes { + effectiveScopeSet[scope] = true + } } userProjects = append(userProjects, objects.UserProjectInfo{ - ProjectID: objects.GUID{Type: ent.TypeProject, ID: up.ProjectID}, - IsOwner: up.IsOwner, - Scopes: up.Scopes, - Roles: projectRoleInfos, + ProjectID: objects.GUID{Type: ent.TypeProject, ID: up.ProjectID}, + IsOwner: up.IsOwner, + Scopes: up.Scopes, + EffectiveScopes: lo.Keys(effectiveScopeSet), + Roles: projectRoleInfos, }) } @@ -365,37 +373,65 @@ func ConvertUserToUserInfo(ctx context.Context, u *ent.User) *objects.UserInfo { // AddUserToProject adds a user to a project with optional owner status, scopes, and roles. func (s *UserService) AddUserToProject(ctx context.Context, userID, projectID int, isOwner *bool, scopes []string, roleIDs []int) (*ent.UserProject, error) { - client := s.entFromContext(ctx) - - // Create the project user relationship - mut := client.UserProject.Create(). - SetUserID(userID). - SetProjectID(projectID) - - if isOwner != nil { - mut.SetIsOwner(*isOwner) + principal, hasPrincipal := authz.GetPrincipal(ctx) + if !hasPrincipal || !principal.IsTest() { + if scopes != nil { + if err := s.permissionValidator.CanGrantScopes(ctx, scopes, &projectID); err != nil { + return nil, fmt.Errorf("permission denied: %w", err) + } + } + for _, roleID := range roleIDs { + projectRole, err := authz.RunWithSystemBypass(ctx, "project-role-assignment", func(ctx context.Context) (*ent.Role, error) { + return s.entFromContext(ctx).Role.Query().Where(role.IDEQ(roleID), role.ProjectIDEQ(projectID)).Only(ctx) + }) + if err != nil { + if ent.IsNotFound(err) { + return nil, fmt.Errorf("project role %d not found", roleID) + } + return nil, fmt.Errorf("failed to load project role %d: %w", roleID, err) + } + if err := s.permissionValidator.CanGrantRole(ctx, projectRole.Scopes, &projectID); err != nil { + return nil, fmt.Errorf("permission denied: %w", err) + } + } } - if scopes != nil { - mut.SetScopes(scopes) - } + var userProject *ent.UserProject + err := s.RunInTransaction(ctx, func(ctx context.Context) error { + client := s.entFromContext(ctx) + mut := client.UserProject.Create(). + SetUserID(userID). + SetProjectID(projectID) - userProject, err := mut.Save(ctx) - if err != nil { - return nil, fmt.Errorf("failed to add user to project: %w", err) - } + if isOwner != nil { + mut.SetIsOwner(*isOwner) + } - // Add roles if provided - if len(roleIDs) > 0 { - user, err := client.User.Get(ctx, userID) + if scopes != nil { + mut.SetScopes(scopes) + } + + created, err := mut.Save(ctx) if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) + return fmt.Errorf("failed to add user to project: %w", err) } + userProject = created - err = user.Update().AddRoleIDs(roleIDs...).Exec(ctx) + if len(roleIDs) == 0 { + return nil + } + user, err := client.User.Get(ctx, userID) if err != nil { - return nil, fmt.Errorf("failed to add roles to user: %w", err) + return fmt.Errorf("failed to get user: %w", err) + } + if err := user.Update().AddRoleIDs(roleIDs...).Exec(ctx); err != nil { + return fmt.Errorf("failed to add roles to user: %w", err) } + + return nil + }) + if err != nil { + return nil, err } // Invalidate user cache diff --git a/internal/server/gql/axonhub.resolvers.go b/internal/server/gql/axonhub.resolvers.go index 252ec7973..d60d2ebc9 100644 --- a/internal/server/gql/axonhub.resolvers.go +++ b/internal/server/gql/axonhub.resolvers.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" + "github.com/looplj/axonhub/internal/authz" "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/apikey" @@ -750,20 +751,49 @@ func (r *queryResolver) AllChannelSummarys(ctx context.Context, includeArchived statusFilter = append(statusFilter, channel.StatusArchived) } - channels, err := r.client.Channel.Query(). - Where(channel.StatusIn(statusFilter...)). - Order(ent.Desc(channel.FieldOrderingWeight)). - All(ctx) - if err != nil { - return nil, fmt.Errorf("failed to query channels: %w", err) - } - projectID, ok := contexts.GetProjectID(ctx) + canReadChannels := authz.HasScope(ctx, scopes.ScopeReadChannels) if !ok || projectID == 0 { + if !canReadChannels { + return nil, authz.RequireScope(ctx, scopes.ScopeReadChannels) + } + channels, err := r.client.Channel.Query(). + Where(channel.StatusIn(statusFilter...)). + Order(ent.Desc(channel.FieldOrderingWeight)). + All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query channels: %w", err) + } return channels, nil } - proj, err := r.client.Project.Get(ctx, projectID) + var ( + channels []*ent.Channel + err error + ) + if canReadChannels { + channels, err = r.client.Channel.Query(). + Where(channel.StatusIn(statusFilter...)). + Order(ent.Desc(channel.FieldOrderingWeight)). + All(ctx) + } else { + if !authz.HasScope(ctx, scopes.ScopeWriteRequests) && !authz.HasScope(ctx, scopes.ScopeWriteAPIKeys) { + return nil, authz.RequireScope(ctx, scopes.ScopeWriteRequests) + } + channels, err = authz.RunWithSystemBypass(ctx, "project-available-channels", func(ctx context.Context) ([]*ent.Channel, error) { + return r.client.Channel.Query(). + Where(channel.StatusEQ(channel.StatusEnabled)). + Order(ent.Desc(channel.FieldOrderingWeight)). + All(ctx) + }) + } + if err != nil { + return nil, fmt.Errorf("failed to query project channels: %w", err) + } + + proj, err := authz.RunWithSystemBypass(ctx, "project-available-channels-profile", func(ctx context.Context) (*ent.Project, error) { + return r.client.Project.Get(ctx, projectID) + }) if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) } diff --git a/internal/server/gql/axonhub_resolvers_test.go b/internal/server/gql/axonhub_resolvers_test.go index be0afc99e..1452beaa7 100644 --- a/internal/server/gql/axonhub_resolvers_test.go +++ b/internal/server/gql/axonhub_resolvers_test.go @@ -77,6 +77,25 @@ func TestQueryResolver_AllChannelSummarys_ProjectProfileUsesIntersection(t *test require.Equal(t, matchingChannel.ID, channels[0].ID) } +func TestQueryResolver_AllChannelSummarys_RequiresChannelReadScopeWithoutProject(t *testing.T) { + resolver, ctx, client := setupTestQueryResolver(t) + defer client.Close() + + _, err := client.Channel.Create(). + SetType(channel.TypeOpenai). + SetName("Protected"). + SetCredentials(objects.ChannelCredentials{APIKey: "key"}). + SetSupportedModels([]string{"protected-model"}). + SetDefaultTestModel("protected-model"). + SetStatus(channel.StatusEnabled). + Save(ctx) + require.NoError(t, err) + + unauthorizedCtx := ent.NewContext(context.Background(), client) + _, err = resolver.AllChannelSummarys(unauthorizedCtx, nil) + require.Error(t, err) +} + func TestQueryResolver_AllChannelTags_ProjectProfileFiltersVisibleTags(t *testing.T) { resolver, ctx, client := setupTestQueryResolver(t) defer client.Close() diff --git a/internal/server/gql/generated.go b/internal/server/gql/generated.go index f65f94df5..ffd8d7a09 100644 --- a/internal/server/gql/generated.go +++ b/internal/server/gql/generated.go @@ -2035,10 +2035,11 @@ type ComplexityRoot struct { } UserProjectInfo struct { - IsOwner func(childComplexity int) int - ProjectID func(childComplexity int) int - Roles func(childComplexity int) int - Scopes func(childComplexity int) int + EffectiveScopes func(childComplexity int) int + IsOwner func(childComplexity int) int + ProjectID func(childComplexity int) int + Roles func(childComplexity int) int + Scopes func(childComplexity int) int } UserRole struct { @@ -11098,6 +11099,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.UserProject.UserID(childComplexity), true + case "UserProjectInfo.effectiveScopes": + if e.complexity.UserProjectInfo.EffectiveScopes == nil { + break + } + + return e.complexity.UserProjectInfo.EffectiveScopes(childComplexity), true case "UserProjectInfo.isOwner": if e.complexity.UserProjectInfo.IsOwner == nil { break @@ -59104,6 +59111,8 @@ func (ec *executionContext) fieldContext_UserInfo_projects(_ context.Context, fi return ec.fieldContext_UserProjectInfo_isOwner(ctx, field) case "scopes": return ec.fieldContext_UserProjectInfo_scopes(ctx, field) + case "effectiveScopes": + return ec.fieldContext_UserProjectInfo_effectiveScopes(ctx, field) case "roles": return ec.fieldContext_UserProjectInfo_roles(ctx, field) } @@ -59605,6 +59614,35 @@ func (ec *executionContext) fieldContext_UserProjectInfo_scopes(_ context.Contex return fc, nil } +func (ec *executionContext) _UserProjectInfo_effectiveScopes(ctx context.Context, field graphql.CollectedField, obj *objects.UserProjectInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UserProjectInfo_effectiveScopes, + func(ctx context.Context) (any, error) { + return obj.EffectiveScopes, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UserProjectInfo_effectiveScopes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UserProjectInfo", + 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) _UserProjectInfo_roles(ctx context.Context, field graphql.CollectedField, obj *objects.UserProjectInfo) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -107387,6 +107425,11 @@ func (ec *executionContext) _UserProjectInfo(ctx context.Context, sel ast.Select if out.Values[i] == graphql.Null { out.Invalids++ } + case "effectiveScopes": + out.Values[i] = ec._UserProjectInfo_effectiveScopes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "roles": out.Values[i] = ec._UserProjectInfo_roles(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/internal/server/gql/me.graphql b/internal/server/gql/me.graphql index 6c19a738e..62d1b0769 100644 --- a/internal/server/gql/me.graphql +++ b/internal/server/gql/me.graphql @@ -30,6 +30,7 @@ type UserProjectInfo { projectID: ID! isOwner: Boolean! scopes: [String!]! + effectiveScopes: [String!]! roles: [RoleInfo!]! } @@ -54,4 +55,4 @@ extend type Mutation { input UpdateMyPasswordInput { oldPassword: String newPassword: String! -} \ No newline at end of file +} diff --git a/internal/server/gql/model.resolvers.go b/internal/server/gql/model.resolvers.go index 920202b9f..7ebd73015 100644 --- a/internal/server/gql/model.resolvers.go +++ b/internal/server/gql/model.resolvers.go @@ -10,6 +10,7 @@ import ( "fmt" "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/contexts" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/ent/model" "github.com/looplj/axonhub/internal/objects" @@ -131,6 +132,18 @@ func (r *queryResolver) FetchModels(ctx context.Context, input biz.FetchModelsIn func (r *queryResolver) QueryModels(ctx context.Context, input QueryModelsInput) ([]*biz.ModelIdentityWithStatus, error) { // Check the QueryAllChannelModels setting settings := r.systemService.ModelSettingsOrDefault(ctx) + listModels := func(list func(context.Context) ([]*biz.ModelIdentityWithStatus, error)) ([]*biz.ModelIdentityWithStatus, error) { + if authz.HasScope(ctx, scopes.ScopeReadChannels) { + return list(ctx) + } + + if _, ok := contexts.GetProjectID(ctx); !ok || + (!authz.HasScope(ctx, scopes.ScopeWriteAPIKeys) && !authz.HasScope(ctx, scopes.ScopeWriteRequests)) { + return nil, authz.RequireScope(ctx, scopes.ScopeReadChannels) + } + + return authz.RunWithSystemBypass(ctx, "project-model-metadata", list) + } if settings.QueryAllChannelModels || lo.FromPtrOr(input.IncludeAllChannelModels, false) { // Convert GraphQL input to biz layer input @@ -142,7 +155,9 @@ func (r *queryResolver) QueryModels(ctx context.Context, input QueryModelsInput) } // Return all models from channels - models, err := r.channelService.ListModels(ctx, bizInput) + models, err := listModels(func(ctx context.Context) ([]*biz.ModelIdentityWithStatus, error) { + return r.channelService.ListModels(ctx, bizInput) + }) if err != nil { return nil, err } @@ -160,7 +175,9 @@ func (r *queryResolver) QueryModels(ctx context.Context, input QueryModelsInput) } } - models, err := r.modelService.ListModels(ctx, modelStatusIn) + models, err := listModels(func(ctx context.Context) ([]*biz.ModelIdentityWithStatus, error) { + return r.modelService.ListModels(ctx, modelStatusIn) + }) if err != nil { return nil, err } diff --git a/internal/server/gql/model_resolvers_test.go b/internal/server/gql/model_resolvers_test.go new file mode 100644 index 000000000..7e1a69915 --- /dev/null +++ b/internal/server/gql/model_resolvers_test.go @@ -0,0 +1,84 @@ +package gql + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/looplj/axonhub/internal/authz" + "github.com/looplj/axonhub/internal/contexts" + "github.com/looplj/axonhub/internal/ent" + "github.com/looplj/axonhub/internal/ent/channel" + "github.com/looplj/axonhub/internal/ent/enttest" + "github.com/looplj/axonhub/internal/ent/role" + "github.com/looplj/axonhub/internal/ent/user" + "github.com/looplj/axonhub/internal/objects" + "github.com/looplj/axonhub/internal/pkg/xcache" + "github.com/looplj/axonhub/internal/server/biz" +) + +func TestQueryModels_ProjectAPIKeyManagerCanReadModels(t *testing.T) { + client := enttest.NewEntClient(t, "sqlite3", "file:query-models-project-access?mode=memory&_fk=1") + defer client.Close() + + setupCtx := authz.WithTestBypass(context.Background()) + project := client.Project.Create().SetName("query-models-project").SaveX(setupCtx) + roleEntity := client.Role.Create(). + SetName("Developer"). + SetLevel(role.LevelProject). + SetProjectID(project.ID). + SetScopes([]string{"write_api_keys"}). + SaveX(setupCtx) + userEntity := client.User.Create(). + SetEmail("query-models-user@example.com"). + SetPassword("password"). + SetStatus(user.StatusActivated). + SaveX(setupCtx) + client.UserProject.Create().SetUserID(userEntity.ID).SetProjectID(project.ID).SaveX(setupCtx) + client.UserRole.Create().SetUserID(userEntity.ID).SetRoleID(roleEntity.ID).SaveX(setupCtx) + client.Channel.Create(). + SetType(channel.TypeOpenai). + SetName("query-models-channel"). + SetCredentials(objects.ChannelCredentials{APIKey: "key"}). + SetSupportedModels([]string{"project-model"}). + SetDefaultTestModel("project-model"). + SetStatus(channel.StatusEnabled). + SaveX(setupCtx) + + userWithEdges := client.User.Query().Where(user.IDEQ(userEntity.ID)).WithRoles().WithProjectUsers().OnlyX(setupCtx) + ctx := ent.NewContext(context.Background(), client) + ctx = authz.NewUserContext(ctx, userWithEdges.ID) + ctx = contexts.WithUser(ctx, userWithEdges) + ctx = contexts.WithProjectID(ctx, project.ID) + + systemService := biz.NewSystemService(biz.SystemServiceParams{ + CacheConfig: xcache.Config{Mode: xcache.ModeMemory}, + Ent: client, + }) + channelService := biz.NewChannelServiceForTest(client) + modelService := biz.NewModelService(biz.ModelServiceParams{ + ChannelService: channelService, + SystemService: systemService, + Ent: client, + }) + resolver := &queryResolver{&Resolver{ + client: client, + systemService: systemService, + channelService: channelService, + modelService: modelService, + }} + + models, err := resolver.QueryModels(ctx, QueryModelsInput{}) + require.NoError(t, err) + require.Contains(t, models, &biz.ModelIdentityWithStatus{ID: "project-model", Status: channel.StatusEnabled}) + channels, err := resolver.AllChannelSummarys(ctx, nil) + require.NoError(t, err) + require.Len(t, channels, 1) + + ctxWithoutProject := ent.NewContext(context.Background(), client) + ctxWithoutProject = authz.NewUserContext(ctxWithoutProject, userWithEdges.ID) + ctxWithoutProject = contexts.WithUser(ctxWithoutProject, userWithEdges) + _, err = resolver.QueryModels(ctxWithoutProject, QueryModelsInput{}) + require.Error(t, err) +}