韩洋 1 місяць тому
батько
коміт
6a1282a73b
43 змінених файлів з 2387 додано та 343 видалено
  1. 21 0
      debug-groups-management-rollback.md
  2. 5 2
      default/src/components/ui/form.tsx
  3. 3 3
      default/src/features/channels/constants.ts
  4. 86 0
      default/src/features/groups/api.ts
  5. 104 0
      default/src/features/groups/components/data-table-row-actions.tsx
  6. 206 0
      default/src/features/groups/components/groups-columns.tsx
  7. 75 0
      default/src/features/groups/components/groups-delete-dialog.tsx
  8. 402 0
      default/src/features/groups/components/groups-mutate-drawer.tsx
  9. 53 0
      default/src/features/groups/components/groups-primary-buttons.tsx
  10. 69 0
      default/src/features/groups/components/groups-provider.tsx
  11. 125 0
      default/src/features/groups/components/groups-table.tsx
  12. 62 0
      default/src/features/groups/index.tsx
  13. 137 0
      default/src/features/groups/types.ts
  14. 6 0
      default/src/features/keys/api.ts
  15. 119 71
      default/src/features/keys/components/api-key-group-combobox.tsx
  16. 81 44
      default/src/features/keys/components/api-keys-columns.tsx
  17. 127 68
      default/src/features/keys/components/api-keys-mutate-drawer.tsx
  18. 62 12
      default/src/features/keys/lib/api-key-form.ts
  19. 24 1
      default/src/features/keys/types.ts
  20. 104 0
      default/src/features/system-settings/billing/group-management-section.tsx
  21. 3 44
      default/src/features/system-settings/billing/section-registry.tsx
  22. 22 0
      default/src/features/tenants/api.ts
  23. 161 43
      default/src/features/tenants/index.tsx
  24. 21 3
      default/src/features/tenants/types.ts
  25. 6 2
      default/src/features/usage-logs/components/columns/common-logs-columns.tsx
  26. 5 1
      default/src/features/usage-logs/components/usage-logs-table.tsx
  27. 4 2
      default/src/features/usage-logs/lib/columns.ts
  28. 1 1
      default/src/features/users/api.ts
  29. 25 6
      default/src/features/users/components/users-columns.tsx
  30. 123 29
      default/src/features/users/components/users-mutate-drawer.tsx
  31. 8 2
      default/src/features/users/components/users-table.tsx
  32. 12 7
      default/src/features/users/lib/user-form.ts
  33. 6 1
      default/src/features/users/types.ts
  34. 27 0
      default/src/i18n/locales/en.json
  35. 1 0
      default/src/i18n/locales/fr.json
  36. 1 0
      default/src/i18n/locales/ja.json
  37. 1 0
      default/src/i18n/locales/ru.json
  38. 1 0
      default/src/i18n/locales/vi.json
  39. 1 0
      default/src/i18n/locales/zh-TW.json
  40. 27 0
      default/src/i18n/locales/zh.json
  41. 4 0
      default/src/lib/admin-permissions.ts
  42. 45 0
      default/src/routes/_authenticated/groups/index.tsx
  43. 11 1
      default/src/routes/_authenticated/usage-logs/$section.tsx

+ 21 - 0
debug-groups-management-rollback.md

@@ -0,0 +1,21 @@
+[OPEN] groups-management-rollback
+
+## Hypotheses
+
+1. 分组管理页引用的表格实现没有真正回退,问题来自权限码常量缺失,导致页面路由或按钮状态异常。
+2. 分组管理列表仍在使用不稳定的路由类型推导,编译错误使这块功能没有处于昨天调通后的可用状态。
+3. 分组 CRUD 抽屉和删除弹窗代码仍在,但页面装配没有和当前权限/路由体系保持一致。
+4. 用户感知的“回退”不是文件丢失,而是昨天新增文件与主项目现有基础设施之间缺少收口,表现为页面不可用或功能残缺。
+
+## Evidence
+
+- `src/features/groups/components/groups-table.tsx` 仍存在完整列表实现。
+- `src/features/groups/components/groups-mutate-drawer.tsx`、`groups-delete-dialog.tsx`、`api.ts` 仍存在 CRUD 代码。
+- `src/lib/admin-permissions.ts` 缺少 `GROUP_VIEW/GROUP_CREATE/GROUP_UPDATE/GROUP_DELETE` 常量。
+- `src/routes/_authenticated/groups/index.tsx` 与 `groups-table.tsx` 存在与当前路由类型不兼容的用法。
+
+## Plan
+
+1. 先修复分组权限常量与列表页路由状态接法。
+2. 确保列表、抽屉、删除弹窗形成完整 CRUD 闭环。
+3. 运行局部类型检查或最小验证,确认恢复到可用状态。

+ 5 - 2
default/src/components/ui/form.tsx

@@ -239,11 +239,14 @@ function FormControl({
   })
 }
 
-function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
+function FormDescription({
+  className,
+  ...props
+}: React.ComponentProps<'div'>) {
   const { formDescriptionId } = useFormField()
 
   return (
-    <p
+    <div
       data-slot='form-description'
       id={formDescriptionId}
       className={cn('text-muted-foreground text-xs leading-relaxed', className)}

+ 3 - 3
default/src/features/channels/constants.ts

@@ -23,7 +23,7 @@ For commercial licensing, please contact support@quantumnous.com
 
 export const CHANNEL_TYPES = {
   0: 'Unknown',
-  // 1: 'OpenAI',
+  1: 'OpenAI',
   // 2: 'MjProxy',
   // 3: 'Azure',
   4: 'Ollama',
@@ -36,7 +36,7 @@ export const CHANNEL_TYPES = {
   // 11: 'PaLM',
   // 12: 'API2GPT',
   // 13: 'AIGC2D',
-  // 14: 'Anthropic',
+  14: 'Anthropic',
   // 15: 'Baidu',
   16: 'Zhipu',
   17: 'Ali',
@@ -46,7 +46,7 @@ export const CHANNEL_TYPES = {
   // 21: 'AI Proxy Library',
   // 22: 'FastGPT',
   // 23: 'Tencent',
-  // 24: 'Gemini',
+  24: 'Gemini',
   25: 'Moonshot',
   26: 'Zhipu V4',
   // 27: 'Perplexity',

+ 86 - 0
default/src/features/groups/api.ts

@@ -0,0 +1,86 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { api } from '@/lib/api'
+
+import type {
+  CreateGroupPayload,
+  CreateGroupResponse,
+  DeleteGroupResponse,
+  GetAvailableModelsResponse,
+  GetGroupsParams,
+  GetGroupsResponse,
+  UpdateGroupPayload,
+  UpdateGroupResponse,
+} from './types'
+
+// ============================================================================
+// Group Management APIs
+// ============================================================================
+
+/**
+ * Get paginated groups list
+ */
+export async function getGroups(
+  params: GetGroupsParams = {}
+): Promise<GetGroupsResponse> {
+  const { p = 1, page_size = 20 } = params
+  const res = await api.get(`/api/groups/?p=${p}&page_size=${page_size}`)
+  return res.data
+}
+
+/**
+ * Get available models for group configuration
+ */
+export async function getAvailableModels(): Promise<GetAvailableModelsResponse> {
+  const res = await api.get('/api/groups/available_models')
+  return res.data
+}
+
+/**
+ * Create a new group
+ */
+export async function createGroup(
+  payload: CreateGroupPayload
+): Promise<CreateGroupResponse> {
+  const res = await api.post('/api/groups/', payload)
+  return res.data
+}
+
+/**
+ * Update an existing group
+ */
+export async function updateGroup(
+  payload: UpdateGroupPayload
+): Promise<UpdateGroupResponse> {
+  const res = await api.put('/api/groups/', payload)
+  return res.data
+}
+
+/**
+ * Delete groups (batch)
+ */
+export async function deleteGroups(
+  ids: string[]
+): Promise<DeleteGroupResponse> {
+  const res = await api.delete('/api/groups/', {
+    data: { ids },
+  })
+  return res.data
+}

+ 104 - 0
default/src/features/groups/components/data-table-row-actions.tsx

@@ -0,0 +1,104 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import type { Row } from '@tanstack/react-table'
+import { Pencil, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { Button } from '@/components/ui/button'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
+
+import type { Group } from '../types'
+import { useGroups } from './groups-provider'
+
+interface DataTableRowActionsProps {
+  row: Row<Group>
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+  const { t } = useTranslation()
+  const group = row.original
+  const { setOpen, setCurrentRow } = useGroups()
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canUpdateGroup = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.GROUP_UPDATE
+  )
+  const canDeleteGroup = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.GROUP_DELETE
+  )
+
+  const handleEdit = () => {
+    setCurrentRow(group)
+    setOpen('update')
+  }
+
+  const handleDelete = () => {
+    setCurrentRow(group)
+    setOpen('delete')
+  }
+
+  return (
+    <div className='-ml-1.5 flex items-center gap-1'>
+      {canUpdateGroup && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleEdit}
+                aria-label={t('Edit')}
+              />
+            }
+          >
+            <Pencil />
+          </TooltipTrigger>
+          <TooltipContent>{t('Edit')}</TooltipContent>
+        </Tooltip>
+      )}
+
+      {canDeleteGroup && (
+        <Tooltip>
+          <TooltipTrigger
+            render={
+              <Button
+                variant='ghost'
+                size='icon-sm'
+                onClick={handleDelete}
+                className='text-destructive hover:text-destructive'
+                aria-label={t('Delete')}
+              />
+            }
+          >
+            <Trash2 />
+          </TooltipTrigger>
+          <TooltipContent>{t('Delete')}</TooltipContent>
+        </Tooltip>
+      )}
+    </div>
+  )
+}

+ 206 - 0
default/src/features/groups/components/groups-columns.tsx

@@ -0,0 +1,206 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import type { ColumnDef } from '@tanstack/react-table'
+import { useTranslation } from 'react-i18next'
+
+import { LongText } from '@/components/long-text'
+import { StatusBadge } from '@/components/status-badge'
+import { Badge } from '@/components/ui/badge'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import dayjs from '@/lib/dayjs'
+
+import type { Group } from '../types'
+import { DataTableRowActions } from './data-table-row-actions'
+
+function formatGroupDateTime(value?: string) {
+  if (!value) return '-'
+  const parsed = dayjs(value)
+  return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : value
+}
+
+export function useGroupsColumns(): ColumnDef<Group>[] {
+  const { t } = useTranslation()
+
+  return [
+    {
+      accessorKey: 'group_name',
+      header: t('Group Name'),
+      cell: ({ row }) => {
+        const group = row.original
+        return (
+          <div className='flex min-w-[160px] items-center gap-3'>
+            <div className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg'>
+              <span className='text-sm font-medium'>
+                {group.group_name.charAt(0).toUpperCase()}
+              </span>
+            </div>
+            <div className='min-w-0 space-y-1'>
+              <LongText className='max-w-[180px] font-medium'>
+                {group.group_name}
+              </LongText>
+              {group.group_desc && (
+                <LongText className='text-muted-foreground max-w-[200px] text-xs'>
+                  {group.group_desc}
+                </LongText>
+              )}
+            </div>
+          </div>
+        )
+      },
+      enableHiding: false,
+      size: 260,
+    },
+    {
+      accessorKey: 'magnification',
+      header: t('Magnification'),
+      cell: ({ row }) => {
+        const magnification = row.getValue('magnification') as number
+        return (
+          <StatusBadge
+            label={`×${magnification.toFixed(1)}`}
+            variant={magnification > 1 ? 'success' : 'neutral'}
+            copyable={false}
+          />
+        )
+      },
+      size: 120,
+    },
+    {
+      accessorKey: 'models',
+      header: t('Models'),
+      cell: ({ row }) => {
+        const models = row.original.models || []
+        const visibleCount = Math.min(models.length, 2)
+        const hiddenCount = models.length - visibleCount
+
+        if (models.length === 0) {
+          return (
+            <StatusBadge
+              label={t('No Models')}
+              variant='neutral'
+              copyable={false}
+            />
+          )
+        }
+
+        return (
+          <div className='flex max-w-[320px] flex-wrap items-center gap-1.5'>
+            {models.slice(0, visibleCount).map((model) => (
+              <Tooltip key={model.model}>
+                <TooltipTrigger
+                  render={
+                    <Badge
+                      variant='secondary'
+                      className='inline-flex max-w-[132px] items-center gap-1 overflow-hidden px-2 py-0.5 text-xs font-medium'
+                    >
+                      <span className='truncate'>{model.model}</span>
+                      {!model.available && (
+                        <span className='text-destructive shrink-0'>⚠</span>
+                      )}
+                    </Badge>
+                  }
+                />
+                <TooltipContent>
+                  <div className='text-xs'>
+                    <div className='font-medium'>{model.model}</div>
+                    <div>
+                      {model.available
+                        ? t('Available')
+                        : model.unavailable_reason || t('Unavailable')}
+                    </div>
+                    <div>
+                      {t('Available channels:')}{' '}
+                      {model.available_channel_count}
+                    </div>
+                  </div>
+                </TooltipContent>
+              </Tooltip>
+            ))}
+            {hiddenCount > 0 && (
+              <Tooltip>
+                <TooltipTrigger
+                  render={
+                    <Badge
+                      variant='outline'
+                      className='px-2 py-0.5 text-xs font-medium'
+                    >
+                      +{hiddenCount}
+                    </Badge>
+                  }
+                />
+                <TooltipContent>
+                  <div className='flex max-w-[240px] flex-wrap gap-1.5 text-xs'>
+                    {models.slice(visibleCount).map((model) => (
+                      <Badge
+                        key={model.model}
+                        variant='secondary'
+                        className='max-w-[180px] px-2 py-0.5 text-xs font-medium'
+                      >
+                        <span className='truncate'>{model.model}</span>
+                      </Badge>
+                    ))}
+                  </div>
+                </TooltipContent>
+              </Tooltip>
+            )}
+          </div>
+        )
+      },
+      size: 300,
+    },
+    {
+      accessorKey: 'created_at',
+      header: t('Created At'),
+      cell: ({ row }) => {
+        const createdAt = row.getValue('created_at') as string | undefined
+        return (
+          <span className='text-muted-foreground text-sm'>
+            {formatGroupDateTime(createdAt)}
+          </span>
+        )
+      },
+      size: 200,
+    },
+    {
+      accessorKey: 'updated_at',
+      header: t('Updated At'),
+      cell: ({ row }) => {
+        const updatedAt = row.getValue('updated_at') as string | undefined
+        return (
+          <span className='text-muted-foreground text-sm'>
+            {formatGroupDateTime(updatedAt)}
+          </span>
+        )
+      },
+      size: 200,
+      meta: { mobileHidden: true },
+    },
+    {
+      id: 'actions',
+      header: t('Actions'),
+      cell: ({ row }) => <DataTableRowActions row={row} />,
+      meta: { pinned: 'right' as const },
+    },
+  ]
+}

+ 75 - 0
default/src/features/groups/components/groups-delete-dialog.tsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+import { deleteGroups } from '../api'
+import { useGroups } from './groups-provider'
+
+export function GroupsDeleteDialog() {
+  const { t } = useTranslation()
+  const { open, setOpen, currentRow, triggerRefresh } = useGroups()
+  const [isDeleting, setIsDeleting] = useState(false)
+
+  const handleDelete = async () => {
+    if (!currentRow) return
+
+    setIsDeleting(true)
+    try {
+      const result = await deleteGroups([currentRow.id])
+      if (result.success) {
+        toast.success(t('Group deleted successfully'))
+        setOpen(null)
+        triggerRefresh()
+      } else {
+        toast.error(result.message || t('Failed to delete group'))
+      }
+    } catch (error) {
+      const apiMessage = (
+        error as { response?: { data?: { message?: string } } }
+      )?.response?.data?.message
+      toast.error(apiMessage || t('Failed to delete group'))
+    } finally {
+      setIsDeleting(false)
+    }
+  }
+
+  return (
+    <ConfirmDialog
+      open={open === 'delete'}
+      onOpenChange={(open) => !open && setOpen(null)}
+      title={t('Delete Group')}
+      desc={
+        <>
+          {t('Are you sure you want to delete group')}{' '}
+          <span className='font-semibold'>{currentRow?.group_name}</span>
+          {t('? This action cannot be undone.')}
+        </>
+      }
+      confirmText={isDeleting ? t('Deleting...') : t('Delete')}
+      destructive
+      isLoading={isDeleting}
+      handleConfirm={handleDelete}
+    />
+  )
+}

+ 402 - 0
default/src/features/groups/components/groups-mutate-drawer.tsx

@@ -0,0 +1,402 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useMemo, useState } from 'react'
+import { useForm, type Resolver } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import {
+  SideDrawerSection,
+  sideDrawerContentClassName,
+  sideDrawerFooterClassName,
+  sideDrawerFormClassName,
+  sideDrawerHeaderClassName,
+} from '@/components/drawer-layout'
+import { Button } from '@/components/ui/button'
+import {
+  Form,
+  FormControl,
+  FormDescription,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+  Sheet,
+  SheetClose,
+  SheetContent,
+  SheetDescription,
+  SheetFooter,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
+import { Textarea } from '@/components/ui/textarea'
+
+import { createGroup, getAvailableModels, updateGroup } from '../api'
+import { groupFormSchema, type Group, type GroupFormValues } from '../types'
+import { useGroups } from './groups-provider'
+
+type GroupsMutateDrawerProps = {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow?: Group
+}
+
+const DEFAULT_FORM_VALUES: GroupFormValues = {
+  group_name: '',
+  group_desc: '',
+  magnification: 1,
+  models: [],
+}
+
+const MAGNIFICATION_OPTIONS = [0.5, 1, 1.5, 2, 3, 5]
+
+export function GroupsMutateDrawer({
+  open,
+  onOpenChange,
+  currentRow,
+}: GroupsMutateDrawerProps) {
+  const { t } = useTranslation()
+  const isUpdate = !!currentRow
+  const { triggerRefresh } = useGroups()
+  const [isSubmitting, setIsSubmitting] = useState(false)
+
+  const { data: availableModelsData, isLoading: isLoadingModels } = useQuery({
+    queryKey: ['groups', 'available-models'],
+    queryFn: getAvailableModels,
+    enabled: open,
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const availableModels = useMemo(
+    () => availableModelsData?.data || [],
+    [availableModelsData?.data]
+  )
+
+  const form = useForm<GroupFormValues>({
+    resolver: zodResolver(groupFormSchema) as unknown as Resolver<GroupFormValues>,
+    defaultValues: DEFAULT_FORM_VALUES,
+  })
+
+  useEffect(() => {
+    if (open && isUpdate && currentRow) {
+      const currentModelNames = (currentRow.models || []).map((m) => m.model)
+      form.reset({
+        group_name: currentRow.group_name,
+        group_desc: currentRow.group_desc || '',
+        magnification: currentRow.magnification || 1,
+        models: currentModelNames,
+      })
+    } else if (open && !isUpdate) {
+      form.reset(DEFAULT_FORM_VALUES)
+    }
+  }, [open, isUpdate, currentRow, form])
+
+  const onSubmit = async (data: GroupFormValues) => {
+    setIsSubmitting(true)
+    try {
+      const payload = {
+        group_name: data.group_name,
+        group_desc: data.group_desc || undefined,
+        magnification:
+          typeof data.magnification === 'number' ? data.magnification : 1,
+        models: data.models.length > 0 ? data.models : undefined,
+      }
+
+      let result
+      if (isUpdate && currentRow) {
+        result = await updateGroup({
+          id: currentRow.id,
+          ...payload,
+        })
+      } else {
+        result = await createGroup(payload)
+      }
+
+      if (result.success) {
+        toast.success(
+          isUpdate ? t('Group updated successfully') : t('Group created successfully')
+        )
+        onOpenChange(false)
+        triggerRefresh()
+      } else {
+        toast.error(result.message || t('Operation failed'))
+      }
+    } catch (error) {
+      const apiMessage = (
+        error as { response?: { data?: { message?: string } } }
+      )?.response?.data?.message
+      toast.error(apiMessage || t('Operation failed'))
+    } finally {
+      setIsSubmitting(false)
+    }
+  }
+
+  const selectedModels = form.watch('models')
+
+  const toggleModel = (modelName: string) => {
+    const current = form.getValues('models')
+    if (current.includes(modelName)) {
+      form.setValue(
+        'models',
+        current.filter((m) => m !== modelName),
+        { shouldValidate: true }
+      )
+    } else {
+      form.setValue('models', [...current, modelName], {
+        shouldValidate: true,
+      })
+    }
+  }
+
+  return (
+    <Sheet
+      open={open}
+      onOpenChange={(v) => {
+        onOpenChange(v)
+        if (!v) {
+          form.reset()
+        }
+      }}
+    >
+      <SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
+        <SheetHeader className={sideDrawerHeaderClassName()}>
+          <SheetTitle>
+            {isUpdate ? t('Edit Group') : t('Create Group')}
+          </SheetTitle>
+          <SheetDescription>
+            {isUpdate
+              ? t('Update group information and model configuration.')
+              : t('Create a new group and configure available models.')}
+          </SheetDescription>
+        </SheetHeader>
+
+        <Form {...form}>
+          <form
+            id='group-form'
+            onSubmit={form.handleSubmit(onSubmit)}
+            className={sideDrawerFormClassName()}
+          >
+            {/* Basic Information */}
+            <SideDrawerSection>
+              <h3 className='text-sm font-medium'>{t('Basic Information')}</h3>
+
+              <FormField
+                control={form.control}
+                name='group_name'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Group Name')} *</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        placeholder={t('Enter group name')}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='group_desc'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Group Description')}</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        {...field}
+                        placeholder={t('Enter group description')}
+                        rows={2}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </SideDrawerSection>
+
+            {/* Magnification */}
+            <SideDrawerSection>
+              <h3 className='text-sm font-medium'>{t('Magnification')}</h3>
+              <p className='text-muted-foreground mt-1 text-xs'>
+                {t('Set the price multiplier for models in this group.')}
+              </p>
+
+              <FormField
+                control={form.control}
+                name='magnification'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>{t('Magnification')}</FormLabel>
+                    <FormControl>
+                      <div className='space-y-4'>
+                        <div className='flex items-center gap-4'>
+                          <span className='text-lg font-semibold tabular-nums'>
+                            ×{typeof field.value === 'number'
+                              ? field.value.toFixed(1)
+                              : '1.0'}
+                          </span>
+                          <Input
+                            type='number'
+                            min={0.1}
+                            max={100}
+                            step={0.1}
+                            value={
+                              typeof field.value === 'number'
+                                ? field.value
+                                : 1
+                            }
+                            onChange={(e) => {
+                              const val = parseFloat(e.target.value)
+                              if (val > 0) {
+                                field.onChange(val)
+                              }
+                            }}
+                            className='w-24'
+                          />
+                        </div>
+                        <div className='flex flex-wrap gap-2'>
+                          {MAGNIFICATION_OPTIONS.map((opt) => (
+                            <Button
+                              key={opt}
+                              type='button'
+                              size='sm'
+                              variant={
+                                typeof field.value === 'number' &&
+                                Math.abs(field.value - opt) < 0.01
+                                  ? 'default'
+                                  : 'outline'
+                              }
+                              onClick={() => field.onChange(opt)}
+                            >
+                              ×{opt}
+                            </Button>
+                          ))}
+                        </div>
+                      </div>
+                    </FormControl>
+                    <FormDescription>
+                      {t('Multiplier must be greater than 0.')}
+                    </FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </SideDrawerSection>
+
+            {/* Model Configuration */}
+            <SideDrawerSection>
+              <h3 className='text-sm font-medium'>{t('Model Configuration')}</h3>
+              <p className='text-muted-foreground mt-1 text-xs'>
+                {t('Select available models for this group.')}
+              </p>
+
+              <FormField
+                control={form.control}
+                name='models'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>
+                      {t('Available Models')} ({selectedModels.length})
+                    </FormLabel>
+                    <FormControl>
+                      {isLoadingModels ? (
+                        <div className='text-muted-foreground rounded-md border p-4 text-center text-sm'>
+                          {t('Loading available models...')}
+                        </div>
+                      ) : availableModels.length === 0 ? (
+                        <div className='text-muted-foreground rounded-md border p-4 text-center text-sm'>
+                          {t('No available models found.')}
+                        </div>
+                      ) : (
+                        <div className='grid max-h-[300px] gap-2 overflow-y-auto rounded-md border p-3 sm:grid-cols-2'>
+                          {availableModels.map((model) => {
+                            const isSelected = field.value.includes(model.model)
+                            return (
+                              <button
+                                key={model.model}
+                                type='button'
+                                onClick={() => toggleModel(model.model)}
+                                disabled={!model.available}
+                                className={`rounded-md border p-2 text-left text-sm transition-colors ${
+                                  isSelected
+                                    ? 'border-primary bg-primary/10'
+                                    : model.available
+                                      ? 'hover:bg-accent border-border/50'
+                                      : 'text-muted-foreground cursor-not-allowed opacity-50'
+                                }`}
+                              >
+                                <div className='flex items-center justify-between'>
+                                  <span className='font-medium'>
+                                    {model.model}
+                                  </span>
+                                  {model.available ? (
+                                    <span className='text-xs text-emerald-500'>
+                                      {t('Available')} ({model.available_channel_count}{' '}
+                                      {t('channels')})
+                                    </span>
+                                  ) : (
+                                    <span className='text-xs text-destructive'>
+                                      {model.unavailable_reason ||
+                                        t('Unavailable')}
+                                    </span>
+                                  )}
+                                </div>
+                              </button>
+                            )
+                          })}
+                        </div>
+                      )}
+                    </FormControl>
+                    <FormDescription>
+                      {t('Click to select or deselect models.')}
+                    </FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+            </SideDrawerSection>
+          </form>
+        </Form>
+
+        <SheetFooter className={sideDrawerFooterClassName()}>
+          <SheetClose
+            render={<Button type='button' variant='outline' />}
+          >
+            {t('Cancel')}
+          </SheetClose>
+          <Button
+            form='group-form'
+            type='submit'
+            disabled={isSubmitting}
+          >
+            {isSubmitting ? t('Saving...') : t('Save')}
+          </Button>
+        </SheetFooter>
+      </SheetContent>
+    </Sheet>
+  )
+}

+ 53 - 0
default/src/features/groups/components/groups-primary-buttons.tsx

@@ -0,0 +1,53 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { Plus } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { Button } from '@/components/ui/button'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
+
+import { useGroups } from './groups-provider'
+
+export function GroupsPrimaryButtons() {
+  const { t } = useTranslation()
+  const { setOpen, setCurrentRow } = useGroups()
+  const currentUser = useAuthStore((s) => s.auth.user)
+  const canCreateGroup = hasPermissionCode(
+    currentUser,
+    PERMISSION_CODES.GROUP_CREATE
+  )
+
+  const handleCreate = () => {
+    setCurrentRow(null)
+    setOpen('create')
+  }
+
+  return (
+    <div className='flex gap-2'>
+      {canCreateGroup && (
+        <Button size='sm' onClick={handleCreate}>
+          <Plus className='h-4 w-4' />
+          {t('Create Group')}
+        </Button>
+      )}
+    </div>
+  )
+}

+ 69 - 0
default/src/features/groups/components/groups-provider.tsx

@@ -0,0 +1,69 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState } from 'react'
+
+import useDialogState from '@/hooks/use-dialog'
+
+import { type Group, type GroupsDialogType } from '../types'
+
+type GroupsContextType = {
+  open: GroupsDialogType | null
+  setOpen: (str: GroupsDialogType | null) => void
+  currentRow: Group | null
+  setCurrentRow: React.Dispatch<React.SetStateAction<Group | null>>
+  refreshTrigger: number
+  triggerRefresh: () => void
+}
+
+const GroupsContext = React.createContext<GroupsContextType | null>(null)
+
+export function GroupsProvider({ children }: { children: React.ReactNode }) {
+  const [open, setOpen] = useDialogState<GroupsDialogType>(null)
+  const [currentRow, setCurrentRow] = useState<Group | null>(null)
+  const [refreshTrigger, setRefreshTrigger] = useState(0)
+
+  const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1)
+
+  return (
+    <GroupsContext.Provider
+      value={{
+        open,
+        setOpen,
+        currentRow,
+        setCurrentRow,
+        refreshTrigger,
+        triggerRefresh,
+      }}
+    >
+      {children}
+    </GroupsContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useGroups = () => {
+  const groupsContext = React.useContext(GroupsContext)
+
+  if (!groupsContext) {
+    throw new Error('useGroups has to be used within <GroupsContext>')
+  }
+
+  return groupsContext
+}

+ 125 - 0
default/src/features/groups/components/groups-table.tsx

@@ -0,0 +1,125 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useQuery } from '@tanstack/react-query'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import {
+  DataTablePage,
+  useDataTable,
+} from '@/components/data-table'
+import { useMediaQuery } from '@/hooks'
+import { useTableUrlState } from '@/hooks/use-table-url-state'
+
+import { getGroups } from '../../groups/api'
+import { useGroupsColumns } from './groups-columns'
+import { useGroups } from './groups-provider'
+
+export function GroupsTable() {
+  const { t } = useTranslation()
+  const columns = useGroupsColumns()
+  const { refreshTrigger } = useGroups()
+  const isMobile = useMediaQuery('(max-width: 640px)')
+
+  const {
+    globalFilter,
+    onGlobalFilterChange,
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  } = useTableUrlState({
+    pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
+    globalFilter: { enabled: true, key: 'filter' },
+  })
+
+  const { data, isLoading, isFetching } = useQuery({
+    queryKey: [
+      'groups',
+      pagination.pageIndex + 1,
+      pagination.pageSize,
+      globalFilter,
+      refreshTrigger,
+    ],
+    queryFn: async () => {
+      const result = await getGroups({
+        p: pagination.pageIndex + 1,
+        page_size: pagination.pageSize,
+      })
+
+      if (!result.success) {
+        toast.error(result.message || t('Failed to load groups'))
+        return { items: [], total: 0 }
+      }
+
+      return {
+        items: result.data?.items || [],
+        total: result.data?.total || 0,
+      }
+    },
+    enabled: true,
+    refetchOnMount: 'always',
+    placeholderData: (previousData) => previousData,
+  })
+
+  const groups = data?.items || []
+
+  const { table } = useDataTable({
+    data: groups,
+    columns,
+    pagination,
+    globalFilter,
+    columnFilters,
+    onPaginationChange,
+    onGlobalFilterChange,
+    onColumnFiltersChange,
+    manualPagination: true,
+    manualFiltering: true,
+    totalCount: data?.total || 0,
+    globalFilterFn: (row, _columnId, filterValue) => {
+      const searchValue = String(filterValue).toLowerCase()
+      const fields = [
+        row.original.group_name,
+        row.original.group_desc || '',
+      ]
+      return fields.some((field) =>
+        String(field || '').toLowerCase().includes(searchValue)
+      )
+    },
+    ensurePageInRange,
+  })
+
+  return (
+    <DataTablePage
+      table={table}
+      columns={columns}
+      isLoading={isLoading}
+      isFetching={isFetching}
+      emptyTitle={t('No Groups Found')}
+      emptyDescription={t('No groups available. Try adjusting your search.')}
+      skeletonKeyPrefix='groups-skeleton'
+      applyHeaderSize
+      toolbarProps={{
+        searchPlaceholder: t('Filter by group name or description...'),
+      }}
+    />
+  )
+}

+ 62 - 0
default/src/features/groups/index.tsx

@@ -0,0 +1,62 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useTranslation } from 'react-i18next'
+
+import { SectionPageLayout } from '@/components/layout'
+
+import { GroupsDeleteDialog } from './components/groups-delete-dialog'
+import { GroupsMutateDrawer } from './components/groups-mutate-drawer'
+import { GroupsPrimaryButtons } from './components/groups-primary-buttons'
+import { GroupsProvider, useGroups } from './components/groups-provider'
+import { GroupsTable } from './components/groups-table'
+
+function GroupsContent() {
+  const { t } = useTranslation()
+  const { open, setOpen, currentRow } = useGroups()
+
+  return (
+    <>
+      <SectionPageLayout fixedContent>
+        <SectionPageLayout.Title>{t('Group Management')}</SectionPageLayout.Title>
+        <SectionPageLayout.Actions>
+          <GroupsPrimaryButtons />
+        </SectionPageLayout.Actions>
+        <SectionPageLayout.Content>
+          <GroupsTable />
+        </SectionPageLayout.Content>
+      </SectionPageLayout>
+
+      <GroupsMutateDrawer
+        open={open === 'create' || open === 'update'}
+        onOpenChange={(isOpen) => !isOpen && setOpen(null)}
+        currentRow={open === 'update' ? currentRow || undefined : undefined}
+      />
+      <GroupsDeleteDialog />
+    </>
+  )
+}
+
+export function Groups() {
+  return (
+    <GroupsProvider>
+      <GroupsContent />
+    </GroupsProvider>
+  )
+}

+ 137 - 0
default/src/features/groups/types.ts

@@ -0,0 +1,137 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { z } from 'zod'
+
+// ============================================================================
+// Group Schema & Types
+// ============================================================================
+
+export const groupModelInfoSchema = z.object({
+  model: z.string(),
+  available: z.boolean(),
+  available_channel_count: z.number().default(0),
+  unavailable_reason: z.string().default(''),
+})
+
+export type GroupModelInfo = z.infer<typeof groupModelInfoSchema>
+
+export const groupSchema = z.object({
+  id: z.string(),
+  tenant_id: z.number().optional(),
+  group_name: z.string(),
+  group_desc: z.string().optional().default(''),
+  magnification: z.number().default(1),
+  models: z.array(groupModelInfoSchema).default([]),
+  created_at: z.string().optional(),
+  updated_at: z.string().optional(),
+})
+
+export type Group = z.infer<typeof groupSchema>
+
+// ============================================================================
+// API Request/Response Types
+// ============================================================================
+
+/** Generic API response */
+export interface ApiResponse<T = unknown> {
+  success: boolean
+  message?: string
+  data?: T
+}
+
+export interface GetGroupsParams {
+  p?: number
+  page_size?: number
+}
+
+export interface GetGroupsResponse {
+  success: boolean
+  message?: string
+  data?: {
+    items: Group[]
+    total: number
+    page: number
+    page_size: number
+  }
+}
+
+export interface GetAvailableModelsResponse {
+  success: boolean
+  message?: string
+  data?: GroupModelInfo[]
+}
+
+export interface CreateGroupPayload {
+  group_name: string
+  group_desc?: string
+  magnification?: number
+  models?: string[]
+}
+
+export interface CreateGroupResponse {
+  success: boolean
+  message?: string
+  data?: Group
+}
+
+export interface UpdateGroupPayload {
+  id: string
+  group_name?: string
+  group_desc?: string
+  magnification?: number
+  models?: string[]
+}
+
+export interface UpdateGroupResponse {
+  success: boolean
+  message?: string
+  data?: {
+    group_id: string
+    group_name?: string
+    group_desc?: string
+    magnification?: number
+    models?: string[]
+  }
+}
+
+export interface DeleteGroupPayload {
+  ids: string[]
+}
+
+export interface DeleteGroupResponse {
+  success: boolean
+  message?: string
+  data?: null
+}
+
+// ============================================================================
+// Form Types
+// ============================================================================
+
+export const groupFormSchema = z.object({
+  group_name: z.string().min(1, '分组名称不能为空'),
+  group_desc: z.string().optional(),
+  magnification: z.number().positive('倍率必须大于0').default(1),
+  models: z.array(z.string()).default([]),
+})
+
+export type GroupFormValues = z.infer<typeof groupFormSchema>
+
+export type GroupsDialogType = 'create' | 'update' | 'delete'

+ 6 - 0
default/src/features/keys/api.ts

@@ -116,3 +116,9 @@ export async function fetchTokenKeysBatch(ids: number[]): Promise<{
   const res = await api.post('/api/token/batch/keys', { ids })
   return res.data
 }
+
+// Get all available groups
+export async function getGroups(): Promise<any> {
+  const res = await api.get('/api/groups/?p=1&page_size=10000')
+  return res.data
+}

+ 119 - 71
default/src/features/keys/components/api-key-group-combobox.tsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 import { ListChecks, XCircle } from 'lucide-react'
-import { useState } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 
 import { MultiSelect, type Option } from '@/components/multi-select'
@@ -25,75 +25,41 @@ import { Button } from '@/components/ui/button'
 import { Checkbox } from '@/components/ui/checkbox'
 import { cn } from '@/lib/utils'
 
+// Model type from group API response
+export type GroupModel = {
+  model: string
+  available: boolean
+  available_channel_count: number
+  unavailable_reason: string
+}
+
 export type ApiKeyGroupOption = {
   value: string
   label: string
   desc?: string
   ratio?: number | string
+  models?: GroupModel[]
+  id?: string // group_id for submission
+}
+
+export type SelectedGroupData = {
+  group_id: string
+  group_name: string
+  models: string[]
 }
 
 type ApiKeyGroupComboboxProps = {
   options: ApiKeyGroupOption[]
   value?: string
   onValueChange: (value: string) => void
+  onGroupsChange?: (groups: SelectedGroupData[]) => void
   placeholder?: string
   disabled?: boolean
   multiple?: boolean
   showModelSelector?: boolean
+  initialSelectedModelsByGroup?: Record<string, string[]> // For initialization
 } & Omit<React.ComponentProps<'div'>, 'onChange'>
 
-const MOCK_MODEL_OPTIONS: Option[] = [
-  { value: 'gpt-4o', label: 'GPT-4o' },
-  { value: 'gpt-4o-mini', label: 'GPT-4o mini' },
-  { value: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet' },
-  { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
-  { value: 'deepseek-v3', label: 'DeepSeek V3' },
-]
-
-// function formatGroupRatio(
-//   ratio: ApiKeyGroupOption['ratio'],
-//   ratioLabel: string
-// ) {
-//   if (ratio === undefined || ratio === null || ratio === '') return null
-//   return `${ratio}x ${ratioLabel}`
-// }
-
-// function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
-//   if (typeof ratio !== 'number') {
-//     return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
-//   }
-
-//   if (ratio > 5) {
-//     return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
-//   }
-//   if (ratio > 3) {
-//     return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
-//   }
-//   if (ratio > 1) {
-//     return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
-//   }
-//   return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
-// }
-
-// function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
-//   const { t } = useTranslation()
-//   const label = formatGroupRatio(ratio, t('Ratio'))
-
-//   if (!label) return null
-
-//   return (
-//     <Badge
-//       variant='outline'
-//       className={cn(
-//         'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
-//         getRatioBadgeClassName(ratio)
-//       )}
-//     >
-//       {label}
-//     </Badge>
-//   )
-// }
-
 function getSelectedGroups(value?: string) {
   return (value || '')
     .split(',')
@@ -105,9 +71,11 @@ export function ApiKeyGroupCombobox({
   options,
   value,
   onValueChange,
+  onGroupsChange,
   disabled,
   multiple = true,
   showModelSelector = true,
+  initialSelectedModelsByGroup,
   className,
   ...props
 }: ApiKeyGroupComboboxProps) {
@@ -116,7 +84,60 @@ export function ApiKeyGroupCombobox({
   const selectedGroupSet = new Set(selectedGroups)
   const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<
     Record<string, string[]>
-  >({})
+  >(initialSelectedModelsByGroup || {})
+  
+  // Track whether we're initializing to prevent infinite loop
+  const isInitializingRef = useRef(true)
+  const prevInitialRef = useRef<string>('')
+
+  // Initialize selected models when initialSelectedModelsByGroup changes
+  useEffect(() => {
+    if (initialSelectedModelsByGroup) {
+      const serialized = JSON.stringify(initialSelectedModelsByGroup)
+      // Only update if the value actually changed
+      if (serialized !== prevInitialRef.current) {
+        prevInitialRef.current = serialized
+        isInitializingRef.current = true
+        
+        // Convert models to string format if needed
+        const converted: Record<string, string[]> = {}
+        for (const [key, models] of Object.entries(initialSelectedModelsByGroup)) {
+          converted[key] = (models || []).map((m: any) => 
+            typeof m === 'string' ? m : (m.model || String(m))
+          )
+        }
+        setSelectedModelsByGroup(converted)
+        
+        // Reset after a tick to allow the state to settle
+        setTimeout(() => {
+          isInitializingRef.current = false
+        }, 0)
+      }
+    }
+  }, [initialSelectedModelsByGroup])
+
+  // Helper function to build groups data for callback
+  const buildGroupsData = useCallback(
+    (
+      groups: string[],
+      modelsByGroup: Record<string, string[]>
+    ): SelectedGroupData[] => {
+      return groups
+        .map((groupName) => {
+          const option = options.find((opt) => opt.value === groupName)
+          return {
+            group_id: option?.id || groupName,
+            group_name: groupName,
+            models: modelsByGroup[groupName] || [],
+          }
+        })
+        .filter((group) => group.group_id) // Only include valid groups
+    },
+    [options]
+  )
+
+  // Note: onGroupsChange is now called directly in user interaction handlers
+  // to avoid infinite loops from useEffect watching state changes
 
   const handleToggle = (optionValue: string) => {
     if (!multiple) {
@@ -129,27 +150,56 @@ export function ApiKeyGroupCombobox({
       : [...selectedGroups, optionValue]
 
     onValueChange(nextGroups.join(','))
+    
+    // Immediately call callback with updated data
+    if (onGroupsChange) {
+      const groupsData = buildGroupsData(nextGroups, selectedModelsByGroup)
+      onGroupsChange(groupsData)
+    }
   }
 
   const handleSelectAllModels = (groupValue: string) => {
-    setSelectedModelsByGroup((current) => ({
-      ...current,
-      [groupValue]: MOCK_MODEL_OPTIONS.map((option) => option.value),
-    }))
+    const groupOption = options.find(opt => opt.value === groupValue)
+    if (!groupOption?.models) return
+    const newModelsByGroup = {
+      ...selectedModelsByGroup,
+      [groupValue]: groupOption.models.map((m) => m.model),
+    }
+    setSelectedModelsByGroup(newModelsByGroup)
+    
+    // Immediately call callback with updated data
+    if (onGroupsChange) {
+      const groupsData = buildGroupsData(selectedGroups, newModelsByGroup)
+      onGroupsChange(groupsData)
+    }
   }
 
   const handleClearAllModels = (groupValue: string) => {
-    setSelectedModelsByGroup((current) => ({
-      ...current,
+    const newModelsByGroup = {
+      ...selectedModelsByGroup,
       [groupValue]: [],
-    }))
+    }
+    setSelectedModelsByGroup(newModelsByGroup)
+    
+    // Immediately call callback with updated data
+    if (onGroupsChange) {
+      const groupsData = buildGroupsData(selectedGroups, newModelsByGroup)
+      onGroupsChange(groupsData)
+    }
   }
 
   const handleModelsChange = (groupValue: string, models: string[]) => {
-    setSelectedModelsByGroup((current) => ({
-      ...current,
+    const newModelsByGroup = {
+      ...selectedModelsByGroup,
       [groupValue]: models,
-    }))
+    }
+    setSelectedModelsByGroup(newModelsByGroup)
+    
+    // Immediately call callback with updated data
+    if (onGroupsChange) {
+      const groupsData = buildGroupsData(selectedGroups, newModelsByGroup)
+      onGroupsChange(groupsData)
+    }
   }
 
   return (
@@ -201,7 +251,7 @@ export function ApiKeyGroupCombobox({
                       type='button'
                       variant='ghost'
                       size='sm'
-                      disabled={disabled || MOCK_MODEL_OPTIONS.length === 0}
+                      disabled={disabled || !option.models || option.models.length === 0}
                       onClick={() => handleSelectAllModels(option.value)}
                       className='text-primary hover:text-primary h-7 px-2 text-xs'
                     >
@@ -222,14 +272,12 @@ export function ApiKeyGroupCombobox({
                   </span>
                 )}
               </div>
-              {showModelSelector && (
+              {showModelSelector && option.models && option.models.length > 0 && (
                 <MultiSelect
-                  options={MOCK_MODEL_OPTIONS}
+                  options={option.models.map(m => ({ value: m.model, label: m.model }))}
                   selected={selectedModels}
-                  onChange={(models) =>
-                    handleModelsChange(option.value, models)
-                  }
-                  placeholder={t('Select items...')}
+                  onChange={(models) => handleModelsChange(option.value, models)}
+                  placeholder={t('Select models...')}
                   disabled={disabled}
                   maxVisibleChips={2}
                 />

+ 81 - 44
default/src/features/keys/components/api-keys-columns.tsx

@@ -20,7 +20,7 @@ import { useQuery } from '@tanstack/react-query'
 import type { ColumnDef } from '@tanstack/react-table'
 import { useTranslation } from 'react-i18next'
 
-import { BadgeCell, TruncatedCell } from '@/components/data-table'
+import { BadgeCell } from '@/components/data-table'
 import { GroupBadge } from '@/components/group-badge'
 import { StatusBadge } from '@/components/status-badge'
 import { Checkbox } from '@/components/ui/checkbox'
@@ -31,7 +31,7 @@ import {
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 import { toIntlLocale } from '@/i18n/languages'
-import { getUserGroups } from '@/lib/api'
+import { getGroups } from '../api'
 import dayjs from '@/lib/dayjs'
 import { formatQuota } from '@/lib/format'
 import { cn } from '@/lib/utils'
@@ -52,20 +52,22 @@ function getQuotaProgressColor(percentage: number): string {
   return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
 }
 
-function useGroupRatios(): Record<string, number> {
+function useGroupRatios(): Record<string, any> {
   const { data } = useQuery({
-    queryKey: ['user-groups'],
-    queryFn: getUserGroups,
-    staleTime: 0,
+    queryKey: ['groups'],
+    queryFn: getGroups,
+    staleTime: 5 * 60 * 1000,
     select: (res) => {
       if (!res.success || !res.data) return {}
-      const ratios: Record<string, number> = {}
-      for (const [group, info] of Object.entries(res.data)) {
-        if (typeof info.ratio === 'number') {
-          ratios[group] = info.ratio
+      const groups = res.data.items || []
+      const groupMap: Record<string, any> = {}
+      for (const group of groups) {
+        groupMap[group.group_name] = {
+          desc: group.group_desc || group.group_name,
+          ratio: group.ratio || group.magnification || 0,
         }
       }
-      return ratios
+      return groupMap
     },
   })
 
@@ -196,49 +198,84 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
       size: 170,
     },
     {
-      accessorKey: 'group',
+      id: 'groups',
       header: t('Group'),
       cell: ({ row }) => {
         const apiKey = row.original
-        const group = row.getValue('group') as string
-        const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
+        const groups = apiKey.groups
+        const hasGroups = Array.isArray(groups) && groups.length > 0
 
-        if (group === 'auto') {
-          return (
-            <Tooltip>
-              <TooltipTrigger
-                render={<BadgeCell className='gap-1.5 text-xs' />}
-              >
-                <GroupBadge group='auto' />
-                {apiKey.cross_group_retry && (
-                  <StatusBadge
-                    label={t('Cross-group')}
-                    variant='info'
-                    copyable={false}
-                  />
-                )}
-              </TooltipTrigger>
-              <TooltipContent>
-                <span className='text-xs'>
-                  {t(
-                    'Automatically selects the best available group with circuit breaker mechanism'
+        if (!hasGroups) {
+          // Fallback to legacy group field
+          const legacyGroup = apiKey.group
+          if (!legacyGroup) {
+            return <span className='text-muted-foreground'>-</span>
+          }
+          if (legacyGroup === 'auto') {
+            return (
+              <Tooltip>
+                <TooltipTrigger
+                  render={<BadgeCell className='gap-1.5 text-xs' />}
+                >
+                  <GroupBadge group='auto' />
+                  {apiKey.cross_group_retry && (
+                    <StatusBadge
+                      label={t('Cross-group')}
+                      variant='info'
+                      copyable={false}
+                    />
                   )}
-                </span>
-              </TooltipContent>
-            </Tooltip>
+                </TooltipTrigger>
+                <TooltipContent>
+                  <span className='text-xs'>
+                    {t(
+                      'Automatically selects the best available group with circuit breaker mechanism'
+                    )}
+                  </span>
+                </TooltipContent>
+              </Tooltip>
+            )
+          }
+          const ratio = groupRatios[legacyGroup]?.ratio
+          return (
+            <GroupBadge group={legacyGroup} label={legacyGroup} ratio={ratio} />
           )
         }
+
+        // New format: display multiple groups
+        const hasAutoGroup = groups.some(
+          (g: any) => g.group_id === 'auto' || g.group_name === 'auto'
+        )
+
         return (
-          <TruncatedCell
-            className='-ml-1.5'
-            tooltipContent={group || '-'}
-            tooltipClassName='break-all'
-          >
-            <GroupBadge group={group} ratio={ratio} />
-          </TruncatedCell>
+          <BadgeCell>
+            <div className='flex flex-wrap gap-1'>
+              {groups.map((g: any, index: number) => {
+                const groupName = g.group_name || g.group_id
+                const ratio = groupName
+                  ? groupRatios[groupName]?.ratio
+                  : undefined
+                return (
+                  <GroupBadge
+                    key={g.group_id || index}
+                    group={groupName}
+                    label={groupName}
+                    ratio={ratio}
+                  />
+                )
+              })}
+              {hasAutoGroup && apiKey.cross_group_retry && (
+                <StatusBadge
+                  label={t('Cross-group')}
+                  variant='info'
+                  copyable={false}
+                />
+              )}
+            </div>
+          </BadgeCell>
         )
       },
-      size: 160,
+      size: 200,
       meta: { mobileHidden: true },
     },
     {

+ 127 - 68
default/src/features/keys/components/api-keys-mutate-drawer.tsx

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery } from '@tanstack/react-query'
 import { KeyRound, WalletCards } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
 import { useForm, type SubmitErrorHandler } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -56,10 +56,9 @@ import {
 } from '@/components/ui/sheet'
 import { Switch } from '@/components/ui/switch'
 import { useStatus } from '@/hooks/use-status'
-import { getUserGroups } from '@/lib/api'
 import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
 
-import { createApiKey, updateApiKey, getApiKey } from '../api'
+import { createApiKey, updateApiKey, getApiKey, getGroups } from '../api'
 import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
 import {
   getApiKeyFormSchema,
@@ -72,6 +71,7 @@ import type { ApiKey } from '../types'
 import {
   ApiKeyGroupCombobox,
   type ApiKeyGroupOption,
+  type SelectedGroupData,
 } from './api-key-group-combobox'
 import { useApiKeys } from './api-keys-provider'
 
@@ -93,24 +93,34 @@ export function ApiKeysMutateDrawer({
   const [isSubmitting, setIsSubmitting] = useState(false)
   const defaultUseAutoGroup = status?.default_use_auto_group === true
 
-  // Fetch groups
+  // State for tracking selected group names and models
+  const [selectedGroupNames, setSelectedGroupNames] = useState<string>('')
+  const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<
+    Record<string, string[]>
+  >({})
+
+  // Fetch groups from API
   const { data: groupsData } = useQuery({
-    queryKey: ['user-groups'],
-    queryFn: getUserGroups,
+    queryKey: ['groups'],
+    queryFn: getGroups,
     enabled: open,
-    staleTime: 0,
+    staleTime: 5 * 60 * 1000,
   })
 
-  const groupsRaw = groupsData?.data || {}
-  const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map(
-    ([key, info]) => ({
-      value: key,
-      label: key,
-      desc: info.desc || key,
-      ratio: info.ratio,
-    })
+  const tenantGroupOptions = useMemo<ApiKeyGroupOption[]>(
+    () => {
+      const groups = (groupsData as any)?.data?.items ?? []
+      return groups.map((group: any) => ({
+        value: group.group_name,
+        label: group.group_name,
+        desc: group.group_desc || group.group_name,
+        models: group.models || [],
+        id: group.id,
+      }))
+    },
+    [(groupsData as any)?.data]
   )
-  const backendHasAuto = groups.some((g) => g.value === 'auto')
+
   const schema = getApiKeyFormSchema(t)
 
   const form = useForm<ApiKeyFormValues>({
@@ -118,43 +128,83 @@ export function ApiKeysMutateDrawer({
     defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup),
   })
 
+  // Handle groups selection change
+  const handleGroupsChange = useCallback(
+    (groups: SelectedGroupData[]) => {
+      const formData = groups.map((g) => ({
+        group_id: g.group_id,
+        models: g.models,
+      }))
+      form.setValue('groups', formData, { shouldValidate: true })
+
+      if (groups.length > 0) {
+        form.clearErrors('groups')
+      }
+
+      const modelsByGroup: Record<string, string[]> = {}
+      groups.forEach((g) => {
+        modelsByGroup[g.group_name] = g.models
+      })
+
+      const currentSerialized = JSON.stringify(selectedModelsByGroup)
+      const newSerialized = JSON.stringify(modelsByGroup)
+      if (currentSerialized !== newSerialized) {
+        setSelectedModelsByGroup(modelsByGroup)
+      }
+    },
+    [form, selectedModelsByGroup]
+  )
+
   // Load existing data when updating
   useEffect(() => {
     if (open && isUpdate && currentRow) {
+      setSelectedGroupNames('')
+      setSelectedModelsByGroup({})
       void getApiKey(currentRow.id).then((result) => {
         if (result.success && result.data) {
-          form.reset(transformApiKeyToFormDefaults(result.data))
+          const apiKeyData = result.data
+          const formDefaults = transformApiKeyToFormDefaults(apiKeyData)
+
+          // Set group selection state for UI display
+          if (
+            apiKeyData.groups &&
+            Array.isArray(apiKeyData.groups) &&
+            apiKeyData.groups.length > 0
+          ) {
+            const groupNames = apiKeyData.groups
+              .map((g: any) => g.group_name || g.group_id)
+              .filter(Boolean)
+              .join(',')
+            setSelectedGroupNames(groupNames)
+
+            const modelsByGroup: Record<string, string[]> = {}
+            apiKeyData.groups.forEach((g: any) => {
+              const groupName = g.group_name || g.group_id
+              if (groupName && g.models) {
+                const models = g.models.map((m: any) =>
+                  typeof m === 'string' ? m : m.model
+                )
+                modelsByGroup[groupName] = models
+              }
+            })
+            setSelectedModelsByGroup(modelsByGroup)
+          } else if (apiKeyData.group) {
+            setSelectedGroupNames(apiKeyData.group)
+            setSelectedModelsByGroup({})
+            formDefaults.groups = [
+              { group_id: apiKeyData.group, models: [] },
+            ]
+          }
+
+          form.reset(formDefaults)
         }
       })
     } else if (open && !isUpdate) {
-      form.reset(
-        getApiKeyFormDefaultValues(defaultUseAutoGroup && backendHasAuto)
-      )
+      setSelectedGroupNames('')
+      setSelectedModelsByGroup({})
+      form.reset(getApiKeyFormDefaultValues(defaultUseAutoGroup))
     }
-  }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, backendHasAuto])
-
-  useEffect(() => {
-    if (groups.length === 0) return
-    const currentGroup = form.getValues('group')
-    const availableGroups = new Set(groups.map((g) => g.value))
-    const validGroups = (currentGroup || '')
-      .split(',')
-      .map((group) => group.trim())
-      .filter((group) => availableGroups.has(group))
-
-    if (currentGroup && validGroups.length === 0) {
-      const fallback =
-        groups.find((g) => g.value === 'default')?.value ??
-        groups[0]?.value ??
-        ''
-      form.setValue('group', fallback)
-      if (currentGroup === 'auto') {
-        form.setValue('cross_group_retry', false)
-      }
-    } else if (currentGroup && validGroups.join(',') !== currentGroup) {
-      form.setValue('group', validGroups.join(','))
-    }
-  }, [groups, form])
+  }, [open, isUpdate, currentRow, form, defaultUseAutoGroup])
 
   const onSubmit = async (data: ApiKeyFormValues) => {
     setIsSubmitting(true)
@@ -174,7 +224,6 @@ export function ApiKeysMutateDrawer({
           toast.error(result.message || t(ERROR_MESSAGES.UPDATE_FAILED))
         }
       } else {
-        // Create mode - handle batch creation
         const count = data.tokenCount || 1
         let successCount = 0
 
@@ -236,11 +285,10 @@ export function ApiKeysMutateDrawer({
   const quotaPlaceholder = tokensOnly
     ? t('Enter quota in tokens')
     : t('Enter quota in {{currency}}', { currency: currencyLabel })
-  const selectedGroup = form.watch('group')
-  const selectedGroups = (selectedGroup || '')
-    .split(',')
-    .map((group) => group.trim())
-    .filter(Boolean)
+  const selectedGroups = form.watch('groups')
+  const hasAutoGroup = selectedGroups?.some(
+    (g) => g.group_id === 'auto'
+  )
   const unlimitedQuota = form.watch('unlimited_quota')
 
   return (
@@ -250,6 +298,8 @@ export function ApiKeysMutateDrawer({
         onOpenChange(v)
         if (!v) {
           form.reset()
+          setSelectedGroupNames('')
+          setSelectedModelsByGroup({})
         }
       }}
     >
@@ -293,26 +343,35 @@ export function ApiKeysMutateDrawer({
                 )}
               />
 
-              <FormField
-                control={form.control}
-                name='group'
-                render={({ field }) => (
-                  <FormItem>
-                    <FormLabel>{t('Group')}</FormLabel>
-                    <FormControl>
-                      <ApiKeyGroupCombobox
-                        options={groups}
-                        value={field.value}
-                        onValueChange={field.onChange}
-                        placeholder={t('Select a group')}
-                      />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
+              <FormItem className='mt-2 w-full min-w-0'>
+                <FormLabel>
+                  {t('Groups')}
+                  <span className='text-destructive ml-1'>*</span>
+                </FormLabel>
+                <FormControl>
+                  <ApiKeyGroupCombobox
+                    options={tenantGroupOptions}
+                    value={selectedGroupNames}
+                    onValueChange={setSelectedGroupNames}
+                    onGroupsChange={handleGroupsChange}
+                    initialSelectedModelsByGroup={selectedModelsByGroup}
+                    placeholder={t('Select groups')}
+                    className='w-full min-w-0'
+                    showModelSelector={true}
+                    multiple={true}
+                  />
+                </FormControl>
+                {/* <FormDescription>
+                  {t('Select groups and models for this API key')}
+                </FormDescription> */}
+                {form.formState.errors.groups && (
+                  <p className='text-destructive text-sm'>
+                    {form.formState.errors.groups.message as string}
+                  </p>
                 )}
-              />
+              </FormItem>
 
-              {selectedGroups.includes('auto') && (
+              {hasAutoGroup && (
                 <FormField
                   control={form.control}
                   name='cross_group_retry'

+ 62 - 12
default/src/features/keys/lib/api-key-form.ts

@@ -21,8 +21,7 @@ import { z } from 'zod'
 
 import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
 
-import { DEFAULT_GROUP } from '../constants'
-import type { ApiKey, ApiKeyFormData } from '../types'
+import type { ApiKey, ApiKeyFormData, ApiKeyGroupData } from '../types'
 
 // ============================================================================
 // Form Schema
@@ -37,7 +36,14 @@ export function getApiKeyFormSchema(t: TFunction) {
       unlimited_quota: z.boolean(),
       model_limits: z.array(z.string()),
       allow_ips: z.string().optional(),
-      group: z.string().optional(),
+      groups: z
+        .array(
+          z.object({
+            group_id: z.string(),
+            models: z.array(z.string()),
+          })
+        )
+        .min(1, t('Please select at least one group')),
       cross_group_retry: z.boolean().optional(),
       tokenCount: z.number().min(1).optional(),
     })
@@ -72,8 +78,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
   unlimited_quota: true,
   model_limits: [],
   allow_ips: '',
-  group: DEFAULT_GROUP,
-  cross_group_retry: true,
+  groups: [],
+  cross_group_retry: false,
   tokenCount: 1,
 }
 
@@ -82,8 +88,8 @@ export function getApiKeyFormDefaultValues(
 ): ApiKeyFormValues {
   return {
     ...API_KEY_FORM_DEFAULT_VALUES,
-    group: defaultUseAutoGroup ? 'auto' : DEFAULT_GROUP,
-    cross_group_retry: defaultUseAutoGroup,
+    groups: [],
+    cross_group_retry: false,
   }
 }
 
@@ -97,6 +103,15 @@ export function getApiKeyFormDefaultValues(
 export function transformFormDataToPayload(
   data: ApiKeyFormValues
 ): ApiKeyFormData {
+  // Filter out the legacy "default" group placeholder with no models, so it is
+  // never sent to the backend when editing old keys or adding new groups.
+  const groups = (data.groups || []).filter(
+    (g: ApiKeyGroupData) => !(g.group_id === 'default' && g.models.length === 0)
+  )
+  const hasAutoGroup = groups.some(
+    (g: ApiKeyGroupData) => g.group_id === 'auto'
+  )
+
   return {
     name: data.name,
     remain_quota: data.unlimited_quota
@@ -109,10 +124,8 @@ export function transformFormDataToPayload(
     model_limits_enabled: data.model_limits.length > 0,
     model_limits: data.model_limits.join(','),
     allow_ips: data.allow_ips || '',
-    group: data.group || '',
-    cross_group_retry: (data.group || '').split(',').includes('auto')
-      ? !!data.cross_group_retry
-      : false,
+    groups,
+    cross_group_retry: hasAutoGroup ? !!data.cross_group_retry : false,
   }
 }
 
@@ -122,6 +135,43 @@ export function transformFormDataToPayload(
 export function transformApiKeyToFormDefaults(
   apiKey: ApiKey
 ): ApiKeyFormValues {
+  // New format: groups array from API response
+  if (apiKey.groups && Array.isArray(apiKey.groups) && apiKey.groups.length > 0) {
+    return {
+      name: apiKey.name,
+      remain_quota_dollars: apiKey.unlimited_quota
+        ? 0
+        : quotaUnitsToDollars(apiKey.remain_quota),
+      expired_time:
+        apiKey.expired_time > 0
+          ? new Date(apiKey.expired_time * 1000)
+          : undefined,
+      unlimited_quota: apiKey.unlimited_quota,
+      model_limits: apiKey.model_limits
+        ? apiKey.model_limits.split(',').filter(Boolean)
+        : [],
+      allow_ips: apiKey.allow_ips || '',
+      groups: apiKey.groups.map((g: any) => ({
+        group_id: g.group_id,
+        models: (g.models || []).map((m: any) =>
+          typeof m === 'string' ? m : m.model
+        ),
+      })),
+      cross_group_retry: !!apiKey.cross_group_retry,
+      tokenCount: 1,
+    }
+  }
+
+  // Legacy format: single group string
+  const legacyGroup = apiKey.group || ''
+  const groups: ApiKeyGroupData[] = []
+  if (legacyGroup) {
+    groups.push({
+      group_id: legacyGroup,
+      models: [],
+    })
+  }
+
   return {
     name: apiKey.name,
     remain_quota_dollars: apiKey.unlimited_quota
@@ -136,7 +186,7 @@ export function transformApiKeyToFormDefaults(
       ? apiKey.model_limits.split(',').filter(Boolean)
       : [],
     allow_ips: apiKey.allow_ips || '',
-    group: apiKey.group || DEFAULT_GROUP,
+    groups,
     cross_group_retry: !!apiKey.cross_group_retry,
     tokenCount: 1,
   }

+ 24 - 1
default/src/features/keys/types.ts

@@ -34,6 +34,24 @@ export const apiKeySchema = z.object({
   created_time: z.number(),
   accessed_time: z.number(),
   group: z.string().nullish().default(''),
+  groups: z
+    .array(
+      z.object({
+        group_id: z.string(),
+        group_name: z.string().optional(),
+        models: z
+          .array(
+            z.preprocess(
+              (m) =>
+                typeof m === 'string' ? m : (m as Record<string, unknown>)?.model ?? '',
+              z.string()
+            )
+          )
+          .default([]),
+      })
+    )
+    .optional()
+    .default([]),
   cross_group_retry: z
     .preprocess((v) => {
       if (v === 1) return true
@@ -82,6 +100,11 @@ export interface SearchApiKeysParams {
   size?: number
 }
 
+export interface ApiKeyGroupData {
+  group_id: string
+  models: string[]
+}
+
 export interface ApiKeyFormData {
   name: string
   remain_quota: number
@@ -90,7 +113,7 @@ export interface ApiKeyFormData {
   model_limits_enabled: boolean
   model_limits: string
   allow_ips: string
-  group: string
+  groups: ApiKeyGroupData[]
   cross_group_retry: boolean
 }
 

+ 104 - 0
default/src/features/system-settings/billing/group-management-section.tsx

@@ -0,0 +1,104 @@
+import { useQuery } from '@tanstack/react-query'
+import type { PaginationState } from '@tanstack/react-table'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { DataTablePage, useDataTable } from '@/components/data-table'
+import { SettingsPageActionsPortal } from '@/features/system-settings/components/settings-page-context'
+import { useMediaQuery } from '@/hooks'
+
+import { getGroups } from '@/features/groups/api'
+import { GroupsDeleteDialog } from '@/features/groups/components/groups-delete-dialog'
+import { GroupsMutateDrawer } from '@/features/groups/components/groups-mutate-drawer'
+import { GroupsPrimaryButtons } from '@/features/groups/components/groups-primary-buttons'
+import { useGroupsColumns } from '@/features/groups/components/groups-columns'
+import { GroupsProvider, useGroups } from '@/features/groups/components/groups-provider'
+
+function BillingGroupManagementContent() {
+  const { t } = useTranslation()
+  const columns = useGroupsColumns()
+  const { open, setOpen, currentRow, refreshTrigger } = useGroups()
+  const isMobile = useMediaQuery('(max-width: 640px)')
+  const [pagination, setPagination] = useState<PaginationState>({
+    pageIndex: 0,
+    pageSize: isMobile ? 10 : 20,
+  })
+
+  const { data, isLoading, isFetching } = useQuery({
+    queryKey: [
+      'system-settings',
+      'billing',
+      'groups',
+      pagination.pageIndex + 1,
+      pagination.pageSize,
+      refreshTrigger,
+    ],
+    queryFn: async () => {
+      const result = await getGroups({
+        p: pagination.pageIndex + 1,
+        page_size: pagination.pageSize,
+      })
+
+      if (!result.success) {
+        toast.error(result.message || t('Failed to load groups'))
+        return { items: [], total: 0 }
+      }
+
+      return {
+        items: result.data?.items || [],
+        total: result.data?.total || 0,
+      }
+    },
+    refetchOnMount: 'always',
+    placeholderData: (previousData) => previousData,
+  })
+
+  const totalCount = data?.total || 0
+
+  const { table } = useDataTable({
+    data: data?.items || [],
+    columns,
+    totalCount,
+    pagination,
+    getRowId: (row) => row.id,
+    onPaginationChange: setPagination,
+    enableRowSelection: false,
+    manualPagination: true,
+  })
+
+  return (
+    <>
+      <SettingsPageActionsPortal>
+        <GroupsPrimaryButtons />
+      </SettingsPageActionsPortal>
+
+      <DataTablePage
+        table={table}
+        columns={columns}
+        isLoading={isLoading}
+        isFetching={isFetching}
+        emptyTitle={t('No Groups Found')}
+        emptyDescription={t(
+          'No groups available. Add your first group to get started.'
+        )}
+        className='h-full min-h-0'
+      />
+
+      <GroupsMutateDrawer
+        open={open === 'create' || open === 'update'}
+        onOpenChange={(isOpen) => !isOpen && setOpen(null)}
+        currentRow={open === 'update' ? currentRow || undefined : undefined}
+      />
+      <GroupsDeleteDialog />
+    </>
+  )
+}
+
+export function BillingGroupManagementSection() {
+  return (
+    <GroupsProvider>
+      <BillingGroupManagementContent />
+    </GroupsProvider>
+  )
+}

+ 3 - 44
default/src/features/system-settings/billing/section-registry.tsx

@@ -16,61 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 
 For commercial licensing, please contact support@quantumnous.com
 */
-import { RatioSettingsCard } from '../models/ratio-settings-card'
 import type { BillingSettings } from '../types'
 import { createSectionRegistry } from '../utils/section-registry'
-
-const getModelDefaults = (settings: BillingSettings) => ({
-  ModelPrice: settings.ModelPrice,
-  ModelRatio: settings.ModelRatio,
-  CacheRatio: settings.CacheRatio,
-  CreateCacheRatio: settings.CreateCacheRatio,
-  CompletionRatio: settings.CompletionRatio,
-  ImageRatio: settings.ImageRatio,
-  AudioRatio: settings.AudioRatio,
-  AudioCompletionRatio: settings.AudioCompletionRatio,
-  ExposeRatioEnabled: settings.ExposeRatioEnabled,
-  BillingMode: settings['billing_setting.billing_mode'],
-  BillingExpr: settings['billing_setting.billing_expr'],
-})
-
-const getGroupDefaults = (settings: BillingSettings) => ({
-  TopupGroupRatio: settings.TopupGroupRatio,
-  GroupRatio: settings.GroupRatio,
-  UserUsableGroups: settings.UserUsableGroups,
-  GroupGroupRatio: settings.GroupGroupRatio,
-  AutoGroups: settings.AutoGroups,
-  DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
-  GroupSpecialUsableGroup:
-    settings['group_ratio_setting.group_special_usable_group'],
-})
+import { BillingGroupManagementSection } from './group-management-section'
 
 const BILLING_SECTIONS = [
   {
     id: 'model-pricing',
     titleKey: 'Model Pricing',
-    build: (settings: BillingSettings) => (
-      <RatioSettingsCard
-        titleKey='Model Pricing'
-        modelDefaults={getModelDefaults(settings)}
-        groupDefaults={getGroupDefaults(settings)}
-        toolPricesDefault={settings['tool_price_setting.prices']}
-        visibleTabs={['models', 'unset-models', 'upstream-sync']}
-      />
-    ),
+    build: () => null,
   },
   {
     id: 'group-pricing',
     titleKey: 'Group Management',
-    build: (settings: BillingSettings) => (
-      <RatioSettingsCard
-        titleKey='Group Management'
-        modelDefaults={getModelDefaults(settings)}
-        groupDefaults={getGroupDefaults(settings)}
-        toolPricesDefault={settings['tool_price_setting.prices']}
-        visibleTabs={['groups']}
-      />
-    ),
+    build: () => <BillingGroupManagementSection />,
   },
 ] as const
 

+ 22 - 0
default/src/features/tenants/api.ts

@@ -45,6 +45,27 @@ function normalizeDirectChildTenant(tenant: RawDirectChildTenant): Tenant {
   const createdAt = toTimestamp(tenant.created_at)
   const updatedAt = toTimestamp(tenant.updated_at)
 
+  // Parse groups from response - handle both new groups array and legacy group_name
+  let tenantGroups: Tenant['groups'] = []
+  if (tenant.groups && Array.isArray(tenant.groups)) {
+    tenantGroups = tenant.groups.map((g: any) => ({
+      group_id: g.group_id || g.group_name || String(g.id || ''),
+      group_name: g.group_name,
+      models: Array.isArray(g.models)
+        ? g.models.map((m: any) => (typeof m === 'string' ? m : m.model || String(m)))
+        : [],
+    }))
+  } else if (tenant.group_name) {
+    // Backward compatibility: convert legacy group_name to groups array
+    tenantGroups = [
+      {
+        group_id: tenant.group_name,
+        group_name: tenant.group_name,
+        models: [],
+      },
+    ]
+  }
+
   return {
     id: tenant.unique_id,
     db_id: tenant.id,
@@ -52,6 +73,7 @@ function normalizeDirectChildTenant(tenant: RawDirectChildTenant): Tenant {
     name: tenant.tenant_name,
     code: tenant.unique_id,
     group: tenant.group_name,
+    groups: tenantGroups,
     contact_phone: tenant.contact_phone ?? '',
     admin_name: tenant.contact_person,
     admin_email: tenant.contact_email ?? '',

+ 161 - 43
default/src/features/tenants/index.tsx

@@ -36,7 +36,7 @@ import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
 import { z } from 'zod'
 
-import { DataTablePage, useDataTable } from '@/components/data-table'
+import { BadgeCell, DataTablePage, useDataTable } from '@/components/data-table'
 import { DatePicker } from '@/components/date-picker'
 import {
   SideDrawerSection,
@@ -45,6 +45,7 @@ import {
   sideDrawerFormClassName,
   sideDrawerHeaderClassName,
 } from '@/components/drawer-layout'
+import { GroupBadge } from '@/components/group-badge'
 import { SectionPageLayout } from '@/components/layout'
 import { LongText } from '@/components/long-text'
 import { StatusBadge, type StatusVariant } from '@/components/status-badge'
@@ -61,6 +62,7 @@ import {
 import {
   Form,
   FormControl,
+  FormDescription,
   FormField,
   FormItem,
   FormLabel,
@@ -92,6 +94,7 @@ import { useAuthStore } from '@/stores/auth-store'
 import {
   ApiKeyGroupCombobox,
   type ApiKeyGroupOption,
+  type SelectedGroupData,
 } from '../keys/components/api-key-group-combobox'
 import { getPermissions, type PermissionItem } from '../permissions/api'
 import { getGroups } from '../users/api'
@@ -101,7 +104,7 @@ import {
   getDirectChildTenants,
   updateDirectChildTenant,
 } from './api'
-import type { Tenant, TenantFormData, TenantStatus } from './types'
+import type { Tenant, TenantFormData, TenantGroupData, TenantStatus } from './types'
 
 const tenantStatusConfig: Record<
   TenantStatus,
@@ -313,6 +316,9 @@ export function Tenants() {
   const [submitAction, setSubmitAction] = useState<'save' | 'saveInvite'>(
     'save'
   )
+  // State for tracking selected group names and models
+  const [selectedGroupNames, setSelectedGroupNames] = useState<string>('')
+  const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<Record<string, string[]>>({})
   const [pagination, setPagination] = useState<PaginationState>({
     pageIndex: 0,
     pageSize: 10,
@@ -392,12 +398,16 @@ export function Tenants() {
   })
 
   const tenantGroupOptions = useMemo<ApiKeyGroupOption[]>(
-    () =>
-      (groupsData?.data ?? []).map((group) => ({
-        value: group,
-        label: group,
-        desc: group,
-      })),
+    () => {
+      const groups = (groupsData as any)?.data?.items ?? []
+      return groups.map((group: any) => ({
+        value: group.group_name,
+        label: group.group_name,
+        desc: group.group_desc || group.group_name,
+        models: group.models || [],
+        id: group.id,
+      }))
+    },
     [groupsData?.data]
   )
 
@@ -416,7 +426,10 @@ export function Tenants() {
   const tenantFormSchema = z
     .object({
       name: z.string().min(1, t('Tenant name is required')),
-      group: z.string().min(1, t('Group is required')),
+      groups: z.array(z.object({
+        group_id: z.string(),
+        models: z.array(z.string())
+      })).min(1, t('Please select at least one group')),
       contact_name: z.string(),
       contact_phone: z.string(),
       contact_email: z
@@ -452,7 +465,7 @@ export function Tenants() {
     ) as unknown as Resolver<TenantFormData>,
     defaultValues: {
       name: '',
-      group: '',
+      groups: [],
       contact_name: '',
       contact_phone: '',
       contact_email: '',
@@ -478,11 +491,73 @@ export function Tenants() {
     )
   }, [directChildTenants, searchValue])
 
+  const handleGroupsChange = useCallback(
+    (groups: SelectedGroupData[]) => {
+      const formData: TenantGroupData[] = groups.map((g) => ({
+        group_id: g.group_id,
+        models: g.models,
+      }))
+      form.setValue('groups', formData, { shouldValidate: true })
+
+      if (groups.length > 0) {
+        form.clearErrors('groups')
+      }
+
+      // Sync selectedModelsByGroup state
+      const modelsByGroup: Record<string, string[]> = {}
+      groups.forEach((g) => {
+        modelsByGroup[g.group_name] = g.models
+      })
+
+      const currentSerialized = JSON.stringify(selectedModelsByGroup)
+      const newSerialized = JSON.stringify(modelsByGroup)
+      if (currentSerialized !== newSerialized) {
+        setSelectedModelsByGroup(modelsByGroup)
+      }
+    },
+    [form, selectedModelsByGroup]
+  )
+
   const resetForm = useCallback(
     (tenant?: Tenant | null) => {
+      // Reset group selection states
+      setSelectedGroupNames('')
+      setSelectedModelsByGroup({})
+
+      // Build groups data for form
+      let groupsData: TenantGroupData[] = []
+      if (tenant?.groups && Array.isArray(tenant.groups) && tenant.groups.length > 0) {
+        groupsData = tenant.groups.map((g) => ({
+          group_id: g.group_id,
+          models: g.models || [],
+        }))
+
+        // Set initial display state
+        const groupNames = tenant.groups
+          .map((g) => g.group_name || g.group_id)
+          .filter(Boolean)
+          .join(',')
+        setSelectedGroupNames(groupNames)
+
+        // Set initial models state
+        const modelsByGroup: Record<string, string[]> = {}
+        tenant.groups.forEach((g) => {
+          const groupName = g.group_name || g.group_id
+          if (groupName && g.models) {
+            modelsByGroup[groupName] = g.models
+          }
+        })
+        setSelectedModelsByGroup(modelsByGroup)
+      } else if (tenant?.group) {
+        // Backward compatibility: convert legacy group to groups format
+        groupsData = [{ group_id: tenant.group, models: [] }]
+        setSelectedGroupNames(tenant.group)
+        setSelectedModelsByGroup({ [tenant.group]: [] })
+      }
+
       form.reset({
         name: tenant?.name ?? '',
-        group: tenant?.group ?? '',
+        groups: groupsData,
         contact_name: tenant?.admin_name ?? '',
         contact_phone: tenant?.contact_phone ?? '',
         contact_email: tenant?.admin_email ?? '',
@@ -554,7 +629,7 @@ export function Tenants() {
             contact_person: data.contact_name || undefined,
             contact_phone: data.contact_phone || undefined,
             contact_email: data.contact_email || undefined,
-            group_name: data.group,
+            groups: data.groups,
             quota_limit: data.quota_limit === '' ? undefined : data.quota_limit,
             start_time: toApiDateTime(data.starts_at, '00:00:00'),
             end_time: toApiDateTime(data.ends_at, '23:59:59'),
@@ -587,7 +662,7 @@ export function Tenants() {
       try {
         const result = await createDirectChildTenant({
           tenant_name: data.name,
-          group_name: data.group,
+          groups: data.groups,
           contact_person: data.contact_name || undefined,
           contact_phone: data.contact_phone || undefined,
           contact_email: data.contact_email || undefined,
@@ -735,25 +810,33 @@ export function Tenants() {
               )}
             />
           </div>
-          <FormField
-            control={form.control}
-            name='group'
-            render={({ field }) => (
-              <FormItem className='mt-4 w-full min-w-0'>
-                <FormLabel>{t('Group')}</FormLabel>
-                <FormControl>
-                  <ApiKeyGroupCombobox
-                    options={tenantGroupOptions}
-                    value={field.value}
-                    onValueChange={field.onChange}
-                    placeholder={t('Select group')}
-                    className='w-full min-w-0'
-                  />
-                </FormControl>
-                <FormMessage />
-              </FormItem>
+          <FormItem className='mt-4 w-full min-w-0'>
+            <FormLabel>
+              {t('Groups')}
+              <span className='text-destructive ml-1'>*</span>
+            </FormLabel>
+            <FormControl>
+              <ApiKeyGroupCombobox
+                options={tenantGroupOptions}
+                value={selectedGroupNames}
+                onValueChange={setSelectedGroupNames}
+                onGroupsChange={handleGroupsChange}
+                initialSelectedModelsByGroup={selectedModelsByGroup}
+                placeholder={t('Select groups')}
+                className='w-full min-w-0'
+                showModelSelector={true}
+                multiple={true}
+              />
+            </FormControl>
+            {/* <FormDescription>
+              {t('Select tenant groups and models for this account')}
+            </FormDescription> */}
+            {form.formState.errors.groups && (
+              <p className="text-destructive text-sm">
+                {form.formState.errors.groups.message as string}
+              </p>
             )}
-          />
+          </FormItem>
         </SideDrawerSection>
 
         <SideDrawerSection>
@@ -932,16 +1015,41 @@ export function Tenants() {
       size: 120,
     },
     {
-      accessorKey: 'group',
+      accessorKey: 'groups',
       header: t('Group'),
-      cell: ({ row }) => (
-        <StatusBadge
-          label={row.getValue('group') as string}
-          autoColor={row.getValue('group') as string}
-          copyable={false}
-        />
-      ),
-      size: 140,
+      cell: ({ row }) => {
+        const tenant = row.original
+        const groups = tenant.groups
+        const hasGroups = Array.isArray(groups) && groups.length > 0
+
+        if (!hasGroups) {
+          // Fallback to legacy group field
+          if (tenant.group) {
+            return (
+              <GroupBadge
+                group={tenant.group}
+                label={tenant.group}
+              />
+            )
+          }
+          return <span className='text-muted-foreground'>-</span>
+        }
+
+        return (
+          <BadgeCell>
+            <div className='flex flex-wrap gap-1'>
+              {groups.map((g, index) => (
+                <GroupBadge
+                  key={g.group_id || index}
+                  group={g.group_name || g.group_id}
+                  label={g.group_name || g.group_id}
+                />
+              ))}
+            </div>
+          </BadgeCell>
+        )
+      },
+      size: 200,
     },
     {
       accessorKey: 'quota_limit',
@@ -1088,9 +1196,19 @@ export function Tenants() {
                       </div>
                     </div>
                     <div className='border-b p-4 sm:border-r'>
-                      <div className='text-muted-foreground'>{t('Group')}</div>
-                      <div className='mt-1 font-medium'>
-                        {detailTenant.group || '-'}
+                      <div className='text-muted-foreground'>{t('Groups')}</div>
+                      <div className='mt-1 flex flex-wrap gap-1'>
+                        {detailTenant.groups && detailTenant.groups.length > 0
+                          ? detailTenant.groups.map((g, index) => (
+                              <GroupBadge
+                                key={g.group_id || index}
+                                group={g.group_name || g.group_id}
+                                label={g.group_name || g.group_id}
+                              />
+                            ))
+                          : detailTenant.group
+                            ? <GroupBadge group={detailTenant.group} label={detailTenant.group} />
+                            : '-'}
                       </div>
                     </div>
                     <div className='border-b p-4'>

+ 21 - 3
default/src/features/tenants/types.ts

@@ -19,6 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
 
 export type TenantStatus = 'active' | 'disabled'
 
+export interface TenantGroupItem {
+  group_id: string
+  group_name?: string
+  models?: string[]
+}
+
 export interface Tenant {
   id: number | string
   db_id?: number | string
@@ -26,6 +32,7 @@ export interface Tenant {
   name: string
   code: string
   group: string
+  groups?: TenantGroupItem[]
   contact_phone: string
   admin_name: string
   admin_email: string
@@ -49,6 +56,11 @@ export interface RawDirectChildTenant {
   tenant_name: string
   tenant_status: number | string
   group_name: string
+  groups?: Array<{
+    group_id?: string
+    group_name?: string
+    models?: Array<string | { model: string; available?: boolean }>
+  }>
   contact_person: string
   contact_phone?: string
   contact_email?: string
@@ -79,9 +91,14 @@ export interface GetDirectChildTenantsResponse {
       }
 }
 
+export interface TenantGroupData {
+  group_id: string
+  models: string[]
+}
+
 export interface CreateDirectChildTenantPayload {
   tenant_name: string
-  group_name: string
+  groups: TenantGroupData[]
   contact_person?: string
   contact_phone?: string
   contact_email?: string
@@ -91,7 +108,8 @@ export interface CreateDirectChildTenantPayload {
   module_codes?: string[]
 }
 
-export interface UpdateDirectChildTenantPayload extends Partial<CreateDirectChildTenantPayload> {
+export interface UpdateDirectChildTenantPayload extends Partial<Omit<CreateDirectChildTenantPayload, 'groups'>> {
+  groups?: TenantGroupData[]
   tenant_status?: number
 }
 
@@ -115,7 +133,7 @@ export interface GetDirectChildTenantDetailResponse {
 
 export interface TenantFormData {
   name: string
-  group: string
+  groups: TenantGroupData[]
   contact_name: string
   contact_phone: string
   contact_email: string

+ 6 - 2
default/src/features/usage-logs/components/columns/common-logs-columns.tsx

@@ -288,8 +288,12 @@ function buildTypeDetailSegments(
   return segments
 }
 
-export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
+export function useCommonLogsColumns(
+  isAdmin: boolean,
+  consumeType?: 'platform' | 'tenant'
+): ColumnDef<UsageLog>[] {
   const { t } = useTranslation()
+  const userColumnHeader = consumeType === 'tenant' ? t('Tenant') : t('User')
   const columns: ColumnDef<UsageLog>[] = [
     {
       accessorKey: 'created_at',
@@ -328,7 +332,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
     columns.push(
       {
         id: 'user',
-        header: t('User'),
+        header: userColumnHeader,
         accessorFn: (row) => row.username,
         cell: function UserCell({ row }) {
           const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =

+ 5 - 1
default/src/features/usage-logs/components/usage-logs-table.tsx

@@ -156,7 +156,11 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
   })
 
   const logs = data?.items || []
-  const columns = useColumnsByCategory(logCategory, isAdmin)
+  const columns = useColumnsByCategory(
+    logCategory,
+    isAdmin,
+    searchParams.consumeType
+  )
   const isLoadingData = isLoading || (isFetching && !data)
 
   const { table } = useDataTable({

+ 4 - 2
default/src/features/usage-logs/lib/columns.ts

@@ -32,10 +32,12 @@ import type { LogCategory } from '../types'
  */
 export function useColumnsByCategory(
   logCategory: LogCategory,
-  isAdmin: boolean
+  isAdmin: boolean,
+  consumeType?: 'platform' | 'tenant'
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 ): ColumnDef<any>[] {
-  const commonColumns = useCommonLogsColumns(isAdmin)
+  // consumeType only affects the shared common/audit table header label.
+  const commonColumns = useCommonLogsColumns(isAdmin, consumeType)
   const drawingColumns = useDrawingLogsColumns(isAdmin)
   const taskColumns = useTaskLogsColumns(isAdmin)
 

+ 1 - 1
default/src/features/users/api.ts

@@ -164,7 +164,7 @@ export async function resetUserTwoFA(id: number): Promise<ApiResponse> {
  * Get all available groups
  */
 export async function getGroups(): Promise<ApiResponse<string[]>> {
-  const res = await api.get('/api/group/')
+  const res = await api.get('/api/groups/?p=1&page_size=10000')
   return res.data
 }
 

+ 25 - 6
default/src/features/users/components/users-columns.tsx

@@ -215,22 +215,41 @@ export function useUsersColumns(): ColumnDef<User>[] {
       meta: { mobileOrder: 40 },
     },
     {
-      accessorKey: 'group',
+      accessorKey: 'groups',
       header: t('Group'),
       cell: ({ row }) => {
-        const group = row.getValue('group') as string
+        const groups = row.original.groups
+        const hasGroups = Array.isArray(groups) && groups.length > 0
+
+        if (!hasGroups) {
+          return <span className='text-muted-foreground'>-</span>
+        }
+
         return (
           <BadgeCell>
-            <GroupBadge group={group} />
+            <div className='flex flex-wrap gap-1'>
+              {groups.map((g: any, index: number) => (
+                <GroupBadge
+                  key={g.group_id || index}
+                  group={g.group_name}
+                  label={g.group_name}
+                />
+              ))}
+            </div>
           </BadgeCell>
         )
       },
       filterFn: (row, id, value) => {
-        const group = String(row.getValue(id) || t('User Group')).toLowerCase()
+        const groups = row.original.groups
+        if (!Array.isArray(groups) || groups.length === 0) {
+          return false
+        }
         const searchValue = String(value).toLowerCase()
-        return group.includes(searchValue)
+        return groups.some((g: any) =>
+          String(g.group_name || '').toLowerCase().includes(searchValue)
+        )
       },
-      size: 140,
+      size: 200,
       meta: { mobileOrder: 30 },
     },
     {

+ 123 - 29
default/src/features/users/components/users-mutate-drawer.tsx

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
 */
 import { zodResolver } from '@hookform/resolvers/zod'
 import { useQuery } from '@tanstack/react-query'
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useState, useCallback } from 'react'
 import { useForm } from 'react-hook-form'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -62,6 +62,7 @@ import { Textarea } from '@/components/ui/textarea'
 import {
   ApiKeyGroupCombobox,
   type ApiKeyGroupOption,
+  type SelectedGroupData,
 } from '@/features/keys/components/api-key-group-combobox'
 import { getRoles } from '@/features/roles/api'
 import { formatQuota } from '@/lib/format'
@@ -69,6 +70,7 @@ import { Pencil } from 'lucide-react'
 
 import { createUser, updateUser, getUser, getGroups } from '../api'
 import { ERROR_MESSAGES, SUCCESS_MESSAGES, isUserRoot } from '../constants'
+import type { UserGroupData } from '../types'
 import {
   userFormSchema,
   type UserFormValues,
@@ -104,6 +106,11 @@ export function UsersMutateDrawer({
 
   const displayQuota = updatedQuota !== null ? updatedQuota : (currentRow?.quota ?? 0)
 
+  // State for tracking selected group names (for component value prop)
+  const [selectedGroupNames, setSelectedGroupNames] = useState<string>('')
+  // State for tracking selected models by group
+  const [selectedModelsByGroup, setSelectedModelsByGroup] = useState<Record<string, string[]>>({})
+
   const { data: rolesData } = useQuery({
     queryKey: ['roles', 'user-form'],
     queryFn: () => getRoles({ p: 1, page_size: 100 }),
@@ -118,14 +125,51 @@ export function UsersMutateDrawer({
 
   const userGroupOptions = useMemo<ApiKeyGroupOption[]>(
     () =>
-      (groupsData?.data ?? []).map((group) => ({
-        value: group,
-        label: group,
-        desc: group,
+      (groupsData?.data?.items ?? []).map((group) => ({
+        value: group.group_name,
+        label: group.group_name,
+        desc: group.group_desc || group.group_name,
+        models: group.models || [],
+        id: group.id, // Add group_id for submission
       })),
     [groupsData?.data]
   )
 
+  const form = useForm<UserFormValues>({
+    resolver: zodResolver(userFormSchema),
+    defaultValues: USER_FORM_DEFAULT_VALUES,
+  })
+
+  // Handle groups selection change (must be after form initialization)
+  const handleGroupsChange = useCallback(
+    (groups: SelectedGroupData[]) => {
+      const formData: UserGroupData[] = groups.map((g) => ({
+        group_id: g.group_id,
+        models: g.models,
+      }))
+      form.setValue('groups', formData, { shouldValidate: true })
+      
+      // Clear error when user selects groups
+      if (groups.length > 0) {
+        form.clearErrors('groups')
+      }
+      
+      // Sync selectedModelsByGroup state - only update if actually changed
+      const modelsByGroup: Record<string, string[]> = {}
+      groups.forEach((g) => {
+        modelsByGroup[g.group_name] = g.models
+      })
+      
+      // Compare with current state to prevent unnecessary updates
+      const currentSerialized = JSON.stringify(selectedModelsByGroup)
+      const newSerialized = JSON.stringify(modelsByGroup)
+      if (currentSerialized !== newSerialized) {
+        setSelectedModelsByGroup(modelsByGroup)
+      }
+    },
+    [form, selectedModelsByGroup]
+  )
+
   const roleOptions = useMemo(
     () =>
       rolesData?.data?.items.map((role) => ({
@@ -141,24 +185,66 @@ export function UsersMutateDrawer({
     [roleOptions]
   )
 
-  const form = useForm<UserFormValues>({
-    resolver: zodResolver(userFormSchema),
-    defaultValues: USER_FORM_DEFAULT_VALUES,
-  })
-
   // Load existing data when updating
   useEffect(() => {
     if (open && isUpdate && currentRow) {
       // For update, fetch fresh data
       setUpdatedQuota(null)
+      setSelectedGroupNames('') // Reset group selection
+      setSelectedModelsByGroup({}) // Reset models selection
       void getUser(currentRow.id).then((result) => {
         if (result.success && result.data) {
-          form.reset(transformUserToFormDefaults(result.data))
+          const userData = result.data
+          // Convert groups data to form defaults
+          const formDefaults = transformUserToFormDefaults(userData)
+          
+          // If user has groups data, set selectedGroupNames and models for UI display
+          if (userData.groups && Array.isArray(userData.groups)) {
+            const groupNames = userData.groups
+              .map((g: any) => g.group_name || g.group_id)
+              .filter(Boolean)
+              .join(',')
+            setSelectedGroupNames(groupNames)
+            
+            // Set selected models by group for initial display
+            const modelsByGroup: Record<string, string[]> = {}
+            userData.groups.forEach((g: any) => {
+              const groupName = g.group_name || g.group_id
+              if (groupName && g.models) {
+                // Handle both string array and object array formats
+                const models = g.models.map((m: any) => 
+                  typeof m === 'string' ? m : m.model
+                )
+                modelsByGroup[groupName] = models
+              }
+            })
+            setSelectedModelsByGroup(modelsByGroup)
+            
+            // Also need to set the form value for submission
+            formDefaults.groups = userData.groups.map((g: any) => ({
+              group_id: g.group_id || g.group_name,
+              // Handle both string array and object array formats
+              models: (g.models || []).map((m: any) => 
+                typeof m === 'string' ? m : m.model
+              ),
+            }))
+          } else if (userData.group) {
+            // Backward compatibility: if user has single group field
+            setSelectedGroupNames(userData.group)
+            setSelectedModelsByGroup({})
+            formDefaults.groups = [
+              { group_id: userData.group, models: [] }
+            ]
+          }
+          
+          form.reset(formDefaults)
         }
       })
     } else if (open && !isUpdate) {
       // For create, reset to defaults
       setUpdatedQuota(null)
+      setSelectedGroupNames('')
+      setSelectedModelsByGroup({})
       form.reset(USER_FORM_DEFAULT_VALUES)
     }
   }, [open, isUpdate, currentRow, form])
@@ -343,25 +429,33 @@ export function UsersMutateDrawer({
                 )}
               />
 
-              <FormField
-                control={form.control}
-                name='group'
-                render={({ field }) => (
-                  <FormItem className='mt-4 w-full min-w-0'>
-                    <FormLabel>{t('Group')}</FormLabel>
-                    <FormControl>
-                      <ApiKeyGroupCombobox
-                        options={userGroupOptions}
-                        value={field.value}
-                        onValueChange={field.onChange}
-                        placeholder={t('Select group')}
-                        className='w-full min-w-0'
-                      />
-                    </FormControl>
-                    <FormMessage />
-                  </FormItem>
+              <FormItem className='mt-4 w-full min-w-0'>
+                <FormLabel>
+                  {t('Groups')}
+                  <span className='text-destructive ml-1'>*</span>
+                </FormLabel>
+                <FormControl>
+                  <ApiKeyGroupCombobox
+                    options={userGroupOptions}
+                    value={selectedGroupNames}
+                    onValueChange={setSelectedGroupNames}
+                    onGroupsChange={handleGroupsChange}
+                    initialSelectedModelsByGroup={selectedModelsByGroup}
+                    placeholder={t('Select groups')}
+                    className='w-full min-w-0'
+                    showModelSelector={true}
+                    multiple={true}
+                  />
+                </FormControl>
+                {/* <FormDescription>
+                  {t('Select user groups and models for this account')}
+                </FormDescription> */}
+                {form.formState.errors.groups && (
+                  <p className="text-destructive text-sm">
+                    {form.formState.errors.groups.message as string}
+                  </p>
                 )}
-              />
+              </FormItem>
 
               <FormField
                 control={form.control}

+ 8 - 2
default/src/features/users/components/users-table.tsx

@@ -78,7 +78,7 @@ export function UsersTable() {
     columnFilters: [
       { columnId: 'status', searchKey: 'status', type: 'array' },
       { columnId: 'role', searchKey: 'role', type: 'array' },
-      { columnId: 'group', searchKey: 'group', type: 'string' },
+      { columnId: 'groups', searchKey: 'groups', type: 'string' },
     ],
   })
   const statusFilter =
@@ -90,7 +90,7 @@ export function UsersTable() {
       | string[]
       | undefined) ?? []
   const groupFilter =
-    (columnFilters.find((filter) => filter.id === 'group')?.value as string) ??
+    (columnFilters.find((filter) => filter.id === 'groups')?.value as string) ??
     ''
 
   // Fetch data with React Query
@@ -198,6 +198,12 @@ export function UsersTable() {
             options: roleOptions,
             singleSelect: true,
           },
+          {
+            columnId: 'groups',
+            title: t('Group'),
+            options: [],
+            singleSelect: true,
+          },
         ],
       }}
       getRowClassName={(row, { isMobile }) =>

+ 12 - 7
default/src/features/users/lib/user-form.ts

@@ -22,7 +22,7 @@ import { z } from 'zod'
 import { quotaUnitsToDollars } from '@/lib/format'
 
 import { DEFAULT_GROUP } from '../constants'
-import { userFormRoleSchema, type UserFormData, type User } from '../types'
+import { userFormRoleSchema, type UserFormData, type UserGroupData, type User } from '../types'
 
 export const USERNAME_MAX_LENGTH = 20
 export const USERNAME_ALLOWED_PATTERN = /^[A-Za-z0-9_]+$/
@@ -59,7 +59,10 @@ export const userFormSchema = z.object({
     .optional(),
   role: userFormRoleSchema.optional(),
   quota_dollars: z.number().min(0).optional(),
-  group: z.string().optional(),
+  groups: z.array(z.object({
+    group_id: z.string(),
+    models: z.array(z.string())
+  })).min(1, i18next.t('Please select at least one group')),
   remark: z.string().optional(),
   admin_permissions: z
     .record(z.string(), z.record(z.string(), z.boolean()))
@@ -78,9 +81,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
   password: '',
   role: 1,
   quota_dollars: 0,
-  group: DEFAULT_GROUP,
+  groups: [],
   remark: '',
-  // Filled against the backend catalog at render time; see UsersMutateDrawer.
   admin_permissions: {},
 }
 
@@ -112,18 +114,21 @@ export function transformFormDataToPayload(
   const roleIds =
     Number.isFinite(numericRole) && numericRole > 0 ? [numericRole] : []
 
+  // Convert groups data to API format
+  if (data.groups && data.groups.length > 0) {
+    payload.groups = data.groups
+  }
+
   // For create: only send required fields
   if (userId === undefined) {
     if (!isRootUser) {
       payload.role_ids = roleIds
     }
-    payload.group = data.group
   } else {
     // For update: quota is adjusted atomically via /api/user/manage, not sent here
     if (!isRootUser) {
       payload.role_ids = roleIds
     }
-    payload.group = data.group
     payload.remark = data.remark || undefined
     payload.id = userId
   }
@@ -149,7 +154,7 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
     password: '',
     role: relationRoleId ?? user.role,
     quota_dollars: quotaUnitsToDollars(user.quota),
-    group: user.group || DEFAULT_GROUP,
+    groups: [], // Will be populated from user's current groups in the drawer
     remark: user.remark || '',
     admin_permissions: user.admin_permissions ?? {},
   }

+ 6 - 1
default/src/features/users/types.ts

@@ -118,13 +118,18 @@ export interface SearchUsersParams {
   page_size?: number
 }
 
+export interface UserGroupData {
+  group_id: string
+  models: string[]
+}
+
 export interface UserFormData {
   username?: string
   display_name: string
   password?: string
   role_ids?: number[]
   quota?: number // Only used when updating user
-  group?: string // Only used when updating user
+  groups?: UserGroupData[] // Array of groups with selected models
   remark?: string // Only used when updating user
 }
 

+ 27 - 0
default/src/i18n/locales/en.json

@@ -1171,6 +1171,7 @@
     "Created a subscription plan": "Created a subscription plan",
     "Created a vendor": "Created a vendor",
     "Created At": "Created At",
+    "Updated At": "Updated At",
     "Created channel {{name}} (type {{type}}, count {{count}})": "Created channel {{name}} (type {{type}}, count {{count}})",
     "Created user {{username}} (role {{role}})": "Created user {{username}} (role {{role}})",
     "Creates a Pancake product in the saved store using this plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Creates a Pancake product in the saved store using this plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.",
@@ -2170,7 +2171,30 @@
     "Grouped monitor status from Uptime Kuma": "Grouped monitor status from Uptime Kuma",
     "Groups": "Groups",
     "Groups *": "Groups *",
+    "Please select at least one group": "Please select at least one group",
     "Groups that users can select when creating API keys.": "Groups that users can select when creating API keys.",
+    "Magnification": "Magnification",
+    "No Models": "No Models",
+    "Create Group": "Create Group",
+    "Edit Group": "Edit Group",
+    "Group Description": "Group Description",
+    "Enter group name": "Enter group name",
+    "Enter group description": "Enter group description",
+    "Set the price multiplier for models in this group.": "Set the price multiplier for models in this group.",
+    "Multiplier must be greater than 0.": "Multiplier must be greater than 0.",
+    "Model Configuration": "Model Configuration",
+    "Select available models for this group.": "Select available models for this group.",
+    "Loading available models...": "Loading available models...",
+    "No available models found.": "No available models found.",
+    "channels": "channels",
+    "Click to select or deselect models.": "Click to select or deselect models.",
+    "Group created successfully": "Group created successfully",
+    "Group updated successfully": "Group updated successfully",
+    "Failed to load groups": "Failed to load groups",
+    "No Groups Found": "No Groups Found",
+    "No groups available. Add your first group to get started.": "No groups available. Add your first group to get started.",
+    "Update group information and model configuration.": "Update group information and model configuration.",
+    "Create a new group and configure available models.": "Create a new group and configure available models.",
     "Growth": "Growth",
     "Guardrails": "Guardrails",
     "Guest": "Guest",
@@ -4061,6 +4085,7 @@
     "Select end time": "Select end time",
     "Select from presets or type custom identifier.": "Select from presets or type custom identifier.",
     "Select granularity": "Select granularity",
+    "Select groups": "Select groups",
     "Select groups (leave empty to keep current)": "Select groups (leave empty to keep current)",
     "Select interface density": "Select interface density",
     "Select items...": "Select items...",
@@ -4071,6 +4096,7 @@
     "Select locations": "Select locations",
     "Select Model": "Select Model",
     "Select model {{model}}": "Select model {{model}}",
+    "Select models...": "Select models...",
     "Select models (empty for allow all)": "Select models (empty for allow all)",
     "Select models and apply to channel models list.": "Select models and apply to channel models list.",
     "Select models or add custom ones": "Select models or add custom ones",
@@ -4102,6 +4128,7 @@
     "Select time granularity": "Select time granularity",
     "Select type": "Select type",
     "Select vendor": "Select vendor",
+    "Select user groups and models for this account": "Select user groups and models for this account",
     "Selectable groups": "Selectable groups",
     "selected": "selected",
     "Selected {{count}}": "Selected {{count}}",

+ 1 - 0
default/src/i18n/locales/fr.json

@@ -2161,6 +2161,7 @@
     "Grouped monitor status from Uptime Kuma": "État des moniteurs groupés depuis Uptime Kuma",
     "Groups": "Groupes",
     "Groups *": "Groupes *",
+    "Please select at least one group": "Veuillez sélectionner au moins un groupe",
     "Groups that users can select when creating API keys.": "Groupes que les utilisateurs peuvent sélectionner lors de la création de clés API.",
     "Growth": "Croissance",
     "Guardrails": "Garde-fous",

+ 1 - 0
default/src/i18n/locales/ja.json

@@ -2161,6 +2161,7 @@
     "Grouped monitor status from Uptime Kuma": "Uptime Kuma からのグループ別監視状態",
     "Groups": "グループ",
     "Groups *": "グループ *",
+    "Please select at least one group": "少なくとも1つのグループを選択してください",
     "Groups that users can select when creating API keys.": "ユーザーが API キー作成時に選択できるグループ。",
     "Growth": "成長",
     "Guardrails": "ガードレール",

+ 1 - 0
default/src/i18n/locales/ru.json

@@ -2161,6 +2161,7 @@
     "Grouped monitor status from Uptime Kuma": "Состояние групп мониторинга из Uptime Kuma",
     "Groups": "Группы",
     "Groups *": "Группы *",
+    "Please select at least one group": "Пожалуйста, выберите хотя бы одну группу",
     "Groups that users can select when creating API keys.": "Группы, которые пользователи могут выбрать при создании ключей API.",
     "Growth": "Рост",
     "Guardrails": "Ограничители",

+ 1 - 0
default/src/i18n/locales/vi.json

@@ -2161,6 +2161,7 @@
     "Grouped monitor status from Uptime Kuma": "Trạng thái giám sát theo nhóm từ Uptime Kuma",
     "Groups": "Nhóm",
     "Groups *": "Nhóm *",
+    "Please select at least one group": "Vui lòng chọn ít nhất một nhóm",
     "Groups that users can select when creating API keys.": "Các nhóm mà người dùng có thể chọn khi tạo khóa API.",
     "Growth": "Tăng trưởng",
     "Guardrails": "Hàng rào bảo vệ",

+ 1 - 0
default/src/i18n/locales/zh-TW.json

@@ -2161,6 +2161,7 @@
     "Grouped monitor status from Uptime Kuma": "來自 Uptime Kuma 的分組監控狀態",
     "Groups": "分組",
     "Groups *": "分組 *",
+    "Please select at least one group": "請至少選擇一個分組",
     "Groups that users can select when creating API keys.": "用戶在建立 API 金鑰時可以選擇的分組。",
     "Growth": "增長",
     "Guardrails": "安全護欄",

+ 27 - 0
default/src/i18n/locales/zh.json

@@ -1168,6 +1168,7 @@
     "Created a subscription plan": "创建了一个订阅计划",
     "Created a vendor": "创建了一个供应商",
     "Created At": "创建时间",
+    "Updated At": "更新时间",
     "Created channel {{name}} (type {{type}}, count {{count}})": "创建渠道 {{name}}(类型 {{type}},数量 {{count}})",
     "Created user {{username}} (role {{role}})": "创建用户 {{username}}(角色 {{role}})",
     "Creates a Pancake product in the saved store using this plan’s title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "使用此套餐的标题和价格,在已保存的店铺中创建 Pancake 产品。需要先在支付设置中完整配置 Waffo Pancake。",
@@ -2167,7 +2168,30 @@
     "Grouped monitor status from Uptime Kuma": "来自 Uptime Kuma 的分组监控状态",
     "Groups": "分组",
     "Groups *": "分组 *",
+    "Please select at least one group": "请至少选择一个分组",
     "Groups that users can select when creating API keys.": "用户在创建 API 密钥时可以选择的分组。",
+    "Magnification": "倍率",
+    "No Models": "无模型",
+    "Create Group": "创建分组",
+    "Edit Group": "编辑分组",
+    "Group Description": "分组描述",
+    "Enter group name": "请输入分组名称",
+    "Enter group description": "请输入分组描述",
+    "Set the price multiplier for models in this group.": "设置该分组中模型的价格倍率。",
+    "Multiplier must be greater than 0.": "倍率必须大于 0。",
+    "Model Configuration": "模型配置",
+    "Select available models for this group.": "选择该分组可用的模型。",
+    "Loading available models...": "正在加载可用模型...",
+    "No available models found.": "未找到可用模型。",
+    "channels": "个渠道",
+    "Click to select or deselect models.": "点击以选择或取消选择模型。",
+    "Group created successfully": "分组创建成功",
+    "Group updated successfully": "分组更新成功",
+    "Failed to load groups": "加载分组失败",
+    "No Groups Found": "未找到分组",
+    "No groups available. Add your first group to get started.": "暂无分组。添加您的第一个分组以开始使用。",
+    "Update group information and model configuration.": "更新分组信息和模型配置。",
+    "Create a new group and configure available models.": "创建新分组并配置可用模型。",
     "Growth": "增长",
     "Guardrails": "安全护栏",
     "Guest": "访客",
@@ -4058,6 +4082,7 @@
     "Select end time": "选择结束时间",
     "Select from presets or type custom identifier.": "从预设中选择或输入自定义标识符。",
     "Select granularity": "选择粒度",
+    "Select groups": "选择分组",
     "Select groups (leave empty to keep current)": "选择分组(留空以保持当前设置)",
     "Select interface density": "选择界面密度",
     "Select items...": "选择项目...",
@@ -4068,6 +4093,7 @@
     "Select locations": "选择位置",
     "Select Model": "选择模型",
     "Select model {{model}}": "选择模型 {{model}}",
+    "Select models...": "选择模型...",
     "Select models (empty for allow all)": "选择模型(留空表示允许所有)",
     "Select models and apply to channel models list.": "选择模型并应用到渠道模型列表。",
     "Select models or add custom ones": "选择模型或添加自定义模型",
@@ -4099,6 +4125,7 @@
     "Select time granularity": "选择时间粒度",
     "Select type": "选择类型",
     "Select vendor": "选择供应商",
+    "Select user groups and models for this account": "选择此账户的用户分组和模型",
     "Selectable groups": "可选分组",
     "selected": "已选择",
     "Selected {{count}}": "已选 {{count}} 个",

+ 4 - 0
default/src/lib/admin-permissions.ts

@@ -38,6 +38,10 @@ export const PERMISSION_CODES = {
   ROLE_CREATE: 'role.create',
   ROLE_UPDATE: 'role.update',
   ROLE_DELETE: 'role.delete',
+  GROUP_VIEW: 'group.view',
+  GROUP_CREATE: 'group.create',
+  GROUP_UPDATE: 'group.update',
+  GROUP_DELETE: 'group.delete',
   TENANT_VIEW: 'tenant.view',
   TENANT_CREATE: 'tenant.create',
   TENANT_UPDATE: 'tenant.update',

+ 45 - 0
default/src/routes/_authenticated/groups/index.tsx

@@ -0,0 +1,45 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { createFileRoute, redirect } from '@tanstack/react-router'
+import z from 'zod'
+
+import { Groups } from '@/features/groups'
+import { PERMISSION_CODES, hasPermissionCode } from '@/lib/admin-permissions'
+import { useAuthStore } from '@/stores/auth-store'
+
+const groupsSearchSchema = z.object({
+  page: z.number().optional().catch(1),
+  pageSize: z.number().optional().catch(undefined),
+  filter: z.string().optional().catch(''),
+})
+
+export const Route = createFileRoute('/_authenticated/groups/')({
+  beforeLoad: () => {
+    const { auth } = useAuthStore.getState()
+
+    if (!hasPermissionCode(auth.user, PERMISSION_CODES.GROUP_VIEW)) {
+      throw redirect({
+        to: '/403',
+      })
+    }
+  },
+  validateSearch: groupsSearchSchema,
+  component: Groups,
+})

+ 11 - 1
default/src/routes/_authenticated/usage-logs/$section.tsx

@@ -39,7 +39,7 @@ const usageLogsSearchSchema = z.object({
   page: z.number().optional().catch(1),
   pageSize: z.number().optional().catch(undefined),
   type: logTypeSearchSchema.optional(),
-  consumeType: z.enum(['platform', 'tenant']).optional().catch('platform'),
+  consumeType: z.enum(['platform', 'tenant']).optional(),
   filter: z.string().optional().catch(''),
   model: z.string().optional().catch(''),
   token: z.string().optional().catch(''),
@@ -79,6 +79,7 @@ export const Route = createFileRoute('/_authenticated/usage-logs/$section')({
     const hasTypeSearch = Array.isArray(search?.type)
       ? search.type.length > 0
       : search?.type != null && search.type !== ''
+    const consumeType = search?.consumeType ?? 'platform'
     if (
       params.section !== 'common' &&
       params.section !== 'audit' &&
@@ -91,6 +92,15 @@ export const Route = createFileRoute('/_authenticated/usage-logs/$section')({
         replace: true,
       })
     }
+
+    if (params.section === 'common' && search?.consumeType !== consumeType) {
+      throw redirect({
+        to: '/usage-logs/$section',
+        params: { section: params.section },
+        search: { ...search, consumeType },
+        replace: true,
+      })
+    }
   },
   validateSearch: usageLogsSearchSchema,
   component: UsageLogs,