Ver Fonte

feat: more ui sync

Seefs há 2 meses atrás
pai
commit
2a0908eae6

+ 85 - 0
web/src/features/channels/api.ts

@@ -1,5 +1,6 @@
 import { api } from '@/lib/api'
 import { getGroups as getUserGroups } from '@/features/users/api'
+import type { AxiosRequestConfig } from 'axios'
 import type {
   AddChannelRequest,
   BatchDeleteParams,
@@ -20,6 +21,53 @@ import type {
   TagOperationParams,
 } from './types'
 
+// Extended API config types
+interface ExtendedApiConfig extends AxiosRequestConfig {
+  skipBusinessError?: boolean
+  disableDuplicate?: boolean
+}
+
+export type CodexOAuthStartResponse = {
+  success: boolean
+  message?: string
+  data?: {
+    authorize_url?: string
+  }
+}
+
+export type CodexOAuthCompleteResponse = {
+  success: boolean
+  message?: string
+  data?: {
+    key?: string
+    account_id?: string
+    email?: string
+    expires_at?: string
+    last_refresh?: string
+  }
+}
+
+export type CodexUsageResponse = {
+  success: boolean
+  message?: string
+  upstream_status?: number
+  data?: any
+}
+
+export type CodexCredentialRefreshResponse = {
+  success: boolean
+  message?: string
+  data?: {
+    expires_at?: string
+    last_refresh?: string
+    account_id?: string
+    email?: string
+    channel_id?: number
+    channel_type?: number
+    channel_name?: string
+  }
+}
+
 // ============================================================================
 // Base Channel CRUD Operations
 // ============================================================================
@@ -186,6 +234,43 @@ export async function getChannelKey(
   return res.data
 }
 
+// ============================================================================
+// Codex Channel Operations
+// ============================================================================
+
+export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
+  const config: ExtendedApiConfig = { skipBusinessError: true }
+  const res = await api.post('/api/channel/codex/oauth/start', {}, config)
+  return res.data
+}
+
+export async function completeCodexOAuth(
+  input: string
+): Promise<CodexOAuthCompleteResponse> {
+  const config: ExtendedApiConfig = { skipBusinessError: true }
+  const res = await api.post('/api/channel/codex/oauth/complete', { input }, config)
+  return res.data
+}
+
+export async function refreshCodexCredential(
+  channelId: number
+): Promise<CodexCredentialRefreshResponse> {
+  const config: ExtendedApiConfig = { skipBusinessError: true }
+  const res = await api.post(`/api/channel/${channelId}/codex/refresh`, {}, config)
+  return res.data
+}
+
+export async function getCodexUsage(
+  channelId: number
+): Promise<CodexUsageResponse> {
+  const config: ExtendedApiConfig = {
+    skipBusinessError: true,
+    disableDuplicate: true,
+  }
+  const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
+  return res.data
+}
+
 // ============================================================================
 // Multi-Key Management
 // ============================================================================

+ 58 - 1
web/src/features/channels/components/channels-columns.tsx

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
 import { type ColumnDef } from '@tanstack/react-table'
 import { ChevronDown, ChevronRight, ListOrdered, Shuffle } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
 import { getCurrencyLabel } from '@/lib/currency'
 import {
   formatTimestampToDate,
@@ -38,10 +39,15 @@ import {
   handleUpdateTagField,
   handleUpdateChannelBalance,
 } from '../lib'
+import { getCodexUsage } from '../api'
 import type { Channel } from '../types'
 import { DataTableRowActions } from './data-table-row-actions'
 import { DataTableTagRowActions } from './data-table-tag-row-actions'
 import { NumericSpinnerInput } from './numeric-spinner-input'
+import {
+  CodexUsageDialog,
+  type CodexUsageDialogData,
+} from './dialogs/codex-usage-dialog'
 
 function parseIonetMeta(otherInfo: string | null | undefined): null | {
   source?: string
@@ -208,6 +214,9 @@ function BalanceCell({ channel }: { channel: Channel }) {
   const balance = channel.balance || 0
   const usedQuota = channel.used_quota || 0
   const [isUpdating, setIsUpdating] = useState(false)
+  const [codexUsageOpen, setCodexUsageOpen] = useState(false)
+  const [codexUsageResponse, setCodexUsageResponse] =
+    useState<CodexUsageDialogData | null>(null)
   const currencyLabel = getCurrencyLabel()
   const tokenSuffix = currencyLabel === 'Tokens' ? ' Tokens' : ''
   const withSuffix = (value: string) =>
@@ -235,6 +244,24 @@ function BalanceCell({ channel }: { channel: Channel }) {
     if (isUpdating) return
 
     setIsUpdating(true)
+    if (channel.type === 57) {
+      try {
+        const res = await getCodexUsage(channel.id)
+        if (!res.success) {
+          throw new Error(res.message || t('Failed to fetch usage'))
+        }
+        setCodexUsageResponse(res)
+        setCodexUsageOpen(true)
+      } catch (error) {
+        toast.error(
+          error instanceof Error ? error.message : t('Failed to fetch usage')
+        )
+      } finally {
+        setIsUpdating(false)
+      }
+      return
+    }
+
     await handleUpdateChannelBalance(channel.id, queryClient)
     setIsUpdating(false)
   }
@@ -275,10 +302,40 @@ function BalanceCell({ channel }: { channel: Channel }) {
             <p>
               {t('Remaining:')} {remainingDisplay}
             </p>
-            <p>{t('Click to update balance')}</p>
+            <p>
+              {channel.type === 57
+                ? t('Click to view Codex usage')
+                : t('Click to update balance')}
+            </p>
           </TooltipContent>
         </Tooltip>
       </div>
+
+      <CodexUsageDialog
+        open={codexUsageOpen}
+        onOpenChange={setCodexUsageOpen}
+        channelName={channel.name}
+        channelId={channel.id}
+        response={codexUsageResponse}
+        onRefresh={async () => {
+          if (isUpdating) return
+          setIsUpdating(true)
+          try {
+            const res = await getCodexUsage(channel.id)
+            if (!res.success) {
+              throw new Error(res.message || t('Failed to fetch usage'))
+            }
+            setCodexUsageResponse(res)
+          } catch (error) {
+            toast.error(
+              error instanceof Error ? error.message : t('Failed to fetch usage')
+            )
+          } finally {
+            setIsUpdating(false)
+          }
+        }}
+        isRefreshing={isUpdating}
+      />
     </TooltipProvider>
   )
 }

+ 48 - 3
web/src/features/channels/components/dialogs/balance-query-dialog.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import { useQueryClient } from '@tanstack/react-query'
 import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
@@ -14,9 +14,11 @@ import {
   DialogHeader,
   DialogTitle,
 } from '@/components/ui/dialog'
-import { updateChannelBalance } from '../../api'
+import { getCodexUsage, updateChannelBalance } from '../../api'
 import { channelsQueryKeys } from '../../lib'
 import { useChannels } from '../channels-provider'
+import { CodexUsageDialog } from './codex-usage-dialog'
+import type { CodexUsageDialogData } from './codex-usage-dialog'
 
 type BalanceQueryDialogProps = {
   open: boolean
@@ -35,9 +37,35 @@ export function BalanceQueryDialog({
   const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
     null
   )
+  const [codexUsageResponse, setCodexUsageResponse] =
+    useState<CodexUsageDialogData | null>(null)
 
   if (!currentRow) return null
 
+  const isCodex = currentRow.type === 57
+
+  const handleQueryCodexUsage = async () => {
+    setIsQuerying(true)
+    try {
+      const res = await getCodexUsage(currentRow.id)
+      if (!res.success) {
+        throw new Error(res.message || t('Failed to fetch usage'))
+      }
+      setCodexUsageResponse(res)
+    } catch (error: any) {
+      toast.error(error?.message || t('Failed to fetch usage'))
+    } finally {
+      setIsQuerying(false)
+    }
+  }
+
+  useEffect(() => {
+    if (!isCodex) return
+    if (!open) return
+    handleQueryCodexUsage()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, isCodex])
+
   const handleQueryBalance = async () => {
     setIsQuerying(true)
     try {
@@ -58,7 +86,7 @@ export function BalanceQueryDialog({
         })
 
         // Invalidate queries to refresh the table
-        queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+        await queryClient.invalidateQueries({queryKey: channelsQueryKeys.lists()})
       } else {
         toast.error(response.message || t('Failed to query balance'))
       }
@@ -72,6 +100,7 @@ export function BalanceQueryDialog({
   const handleClose = () => {
     setBalance(null)
     setBalanceUpdatedTime(null)
+    setCodexUsageResponse(null)
     onOpenChange(false)
   }
 
@@ -87,6 +116,22 @@ export function BalanceQueryDialog({
     return formatTimestampToDate(timestamp)
   }
 
+  if (isCodex) {
+    return (
+      <CodexUsageDialog
+        open={open}
+        onOpenChange={(v) => {
+          if (!v) handleClose()
+        }}
+        channelName={currentRow.name}
+        channelId={currentRow.id}
+        response={codexUsageResponse}
+        onRefresh={handleQueryCodexUsage}
+        isRefreshing={isQuerying}
+      />
+    )
+  }
+
   return (
     <Dialog open={open} onOpenChange={handleClose}>
       <DialogContent>

+ 198 - 0
web/src/features/channels/components/dialogs/codex-oauth-dialog.tsx

@@ -0,0 +1,198 @@
+import { useEffect, useMemo, useState } from 'react'
+import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+import { tryPrettyJson } from '@/lib/utils'
+import { completeCodexOAuth, startCodexOAuth } from '../../api'
+
+type CodexOAuthDialogProps = {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onKeyGenerated: (key: string) => void
+}
+
+export function CodexOAuthDialog({
+  open,
+  onOpenChange,
+  onKeyGenerated,
+}: CodexOAuthDialogProps) {
+  const { t } = useTranslation()
+  const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
+
+  const [state, setState] = useState({
+    authorizeUrl: '',
+    callbackUrl: '',
+    isStarting: false,
+    isCompleting: false,
+  })
+
+  useEffect(() => {
+    if (!open) {
+      setState({
+        authorizeUrl: '',
+        callbackUrl: '',
+        isStarting: false,
+        isCompleting: false,
+      })
+    }
+  }, [open])
+
+  const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting)
+  const canComplete = useMemo(
+    () => Boolean(state.callbackUrl.trim()) && !state.isCompleting,
+    [state.callbackUrl, state.isCompleting]
+  )
+
+  const handleStart = async () => {
+    setState((prev) => ({ ...prev, isStarting: true }))
+    try {
+      const res = await startCodexOAuth()
+      if (!res.success) {
+        throw new Error(res.message || 'Failed to start OAuth')
+      }
+
+      const url = res.data?.authorize_url || ''
+      if (!url) {
+        throw new Error('Missing authorize_url in response')
+      }
+
+      setState((prev) => ({ ...prev, authorizeUrl: url }))
+      try {
+        window.open(url, '_blank', 'noopener,noreferrer')
+        toast.success(t('Opened authorization page'))
+      } catch (error) {
+        console.warn('Failed to open authorization page:', error)
+        toast.warning(t('Please manually copy and open the authorization link'))
+      }
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : t('OAuth start failed'))
+    } finally {
+      setState((prev) => ({ ...prev, isStarting: false }))
+    }
+  }
+
+  const handleComplete = async () => {
+    if (!state.callbackUrl.trim()) return
+    setState((prev) => ({ ...prev, isCompleting: true }))
+    try {
+      const res = await completeCodexOAuth(state.callbackUrl.trim())
+      if (!res.success) {
+        throw new Error(res.message || 'OAuth failed')
+      }
+
+      const rawKey = res.data?.key || ''
+      if (!rawKey) {
+        throw new Error('Missing key in response')
+      }
+
+      onKeyGenerated(tryPrettyJson(rawKey))
+      toast.success(t('Credential generated'))
+      onOpenChange(false)
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : t('OAuth failed'))
+    } finally {
+      setState((prev) => ({ ...prev, isCompleting: false }))
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-2xl'>
+        <DialogHeader>
+          <DialogTitle>{t('Codex Authorization')}</DialogTitle>
+          <DialogDescription>
+            {t(
+              'Generate a Codex OAuth credential and paste it into the channel key field.'
+            )}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className='space-y-4'>
+          <Alert>
+            <AlertDescription>
+              {t(
+                '1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
+              )}
+            </AlertDescription>
+          </Alert>
+
+          <div className='flex flex-wrap gap-2'>
+            <Button onClick={handleStart} disabled={state.isStarting}>
+              {state.isStarting ? (
+                <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+              ) : (
+                <ExternalLink className='mr-2 h-4 w-4' />
+              )}
+              {t('Open authorization page')}
+            </Button>
+
+            <Button
+              type='button'
+              variant='outline'
+              disabled={!canCopyAuthorizeUrl}
+              onClick={async () => {
+                if (!state.authorizeUrl) return
+                await copyToClipboard(state.authorizeUrl)
+              }}
+              aria-label={t('Copy authorization link')}
+              title={t('Copy authorization link')}
+            >
+              {copiedText === state.authorizeUrl ? (
+                <Check className='mr-2 h-4 w-4 text-green-600' />
+              ) : (
+                <Copy className='mr-2 h-4 w-4' />
+              )}
+              {t('Copy authorization link')}
+            </Button>
+          </div>
+
+          <div className='space-y-2'>
+            <div className='text-sm font-medium'>{t('Callback URL')}</div>
+            <Input
+              value={state.callbackUrl}
+              onChange={(e) =>
+                setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
+              }
+              placeholder={t('Paste the full callback URL (includes code & state)')}
+              autoComplete='off'
+              spellCheck={false}
+            />
+            <div className='text-muted-foreground text-xs'>
+              {t(
+                'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
+              )}
+            </div>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button
+            type='button'
+            variant='outline'
+            onClick={() => onOpenChange(false)}
+            disabled={state.isStarting || state.isCompleting}
+          >
+            {t('Cancel')}
+          </Button>
+          <Button onClick={handleComplete} disabled={!canComplete}>
+            {state.isCompleting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
+            {state.isCompleting ? t('Generating...') : t('Generate credential')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
+

+ 243 - 0
web/src/features/channels/components/dialogs/codex-usage-dialog.tsx

@@ -0,0 +1,243 @@
+import { useMemo } from 'react'
+import { Copy, Check, RefreshCw } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Progress } from '@/components/ui/progress'
+import { StatusBadge } from '@/components/status-badge'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+import type { StatusBadgeProps } from '@/components/status-badge'
+
+type CodexRateLimitWindow = {
+  used_percent?: number
+  reset_at?: number
+  reset_after_seconds?: number
+  limit_window_seconds?: number
+}
+
+type CodexUsagePayload = {
+  rate_limit?: {
+    allowed?: boolean
+    limit_reached?: boolean
+    primary_window?: CodexRateLimitWindow
+    secondary_window?: CodexRateLimitWindow
+  }
+}
+
+export type CodexUsageDialogData = {
+  success: boolean
+  message?: string
+  upstream_status?: number
+  data?: any
+}
+
+type CodexUsageDialogProps = {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  channelName?: string
+  channelId?: number
+  response: CodexUsageDialogData | null
+  onRefresh?: () => void
+  isRefreshing?: boolean
+}
+
+function clampPercent(value: unknown): number {
+  const v = Number(value)
+  return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0
+}
+
+function formatUnixSeconds(unixSeconds: unknown): string {
+  const v = Number(unixSeconds)
+  if (!Number.isFinite(v) || v <= 0) return '-'
+  try {
+    return new Date(v * 1000).toLocaleString()
+  } catch {
+    return String(unixSeconds)
+  }
+}
+
+function formatDurationSeconds(seconds: unknown): string {
+  const s = Number(seconds)
+  if (!Number.isFinite(s) || s <= 0) return '-'
+
+  const total = Math.floor(s)
+  const hours = Math.floor(total / 3600)
+  const minutes = Math.floor((total % 3600) / 60)
+  const secs = total % 60
+
+  if (hours > 0) return `${hours}h ${minutes}m`
+  if (minutes > 0) return `${minutes}m ${secs}s`
+  return `${secs}s`
+}
+
+function windowLabel(windowData?: CodexRateLimitWindow) {
+  const percent = clampPercent(windowData?.used_percent)
+  const variant: StatusBadgeProps['variant'] =
+    percent >= 95 ? 'danger' : percent >= 80 ? 'warning' : 'info'
+  return { percent, variant }
+}
+
+type RateLimitWindowProps = {
+  title: string
+  window?: CodexRateLimitWindow
+}
+
+function RateLimitWindow({ title, window }: RateLimitWindowProps) {
+  const { t } = useTranslation()
+  const { percent, variant } = windowLabel(window)
+
+  return (
+    <div className='rounded-lg border p-4'>
+      <div className='flex items-center justify-between gap-2'>
+        <div className='text-sm font-medium'>{title}</div>
+        <StatusBadge label={`${percent}%`} variant={variant} copyable={false} />
+      </div>
+      <div className='mt-3'>
+        <Progress value={percent} aria-label={`${title} usage: ${percent}%`} />
+      </div>
+      <div className='text-muted-foreground mt-2 space-y-1 text-xs'>
+        <div>
+          {t('Reset at:')} {formatUnixSeconds(window?.reset_at)}
+        </div>
+        <div>
+          {t('Resets in:')} {formatDurationSeconds(window?.reset_after_seconds)}
+        </div>
+        <div>
+          {t('Window:')} {formatDurationSeconds(window?.limit_window_seconds)}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function CodexUsageDialog({
+  open,
+  onOpenChange,
+  channelName,
+  channelId,
+  response,
+  onRefresh,
+  isRefreshing,
+}: CodexUsageDialogProps) {
+  const { t } = useTranslation()
+  const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
+
+  const payload: CodexUsagePayload | null = useMemo(() => {
+    const raw = response?.data
+    if (!raw || typeof raw !== 'object') return null
+    return raw as CodexUsagePayload
+  }, [response?.data])
+
+  const rateLimit = payload?.rate_limit
+  const primary = rateLimit?.primary_window
+  const secondary = rateLimit?.secondary_window
+
+  const statusBadge = (() => {
+    const allowed = Boolean(rateLimit?.allowed)
+    const limitReached = Boolean(rateLimit?.limit_reached)
+    if (allowed && !limitReached) {
+      return <StatusBadge label={t('Allowed')} variant='success' copyable={false} />
+    }
+    return <StatusBadge label={t('Limited')} variant='danger' copyable={false} />
+  })()
+
+  const rawJsonText = useMemo(() => {
+    if (!response) return ''
+    try {
+      return JSON.stringify(
+        {
+          success: response.success,
+          message: response.message,
+          upstream_status: response.upstream_status,
+          data: response.data,
+        },
+        null,
+        2
+      )
+    } catch {
+      return String(response?.data ?? '')
+    }
+  }, [response])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-3xl'>
+        <DialogHeader>
+          <DialogTitle className='flex items-center gap-2'>
+            {t('Codex Usage')}
+            {statusBadge}
+          </DialogTitle>
+          <DialogDescription>
+            {t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
+            {channelId ? `(#${channelId})` : ''}
+            {typeof response?.upstream_status === 'number'
+              ? ` - ${t('Upstream status:')} ${response.upstream_status}`
+              : ''}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className='space-y-4'>
+          <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
+            <RateLimitWindow title={t('Primary window')} window={primary} />
+            <RateLimitWindow title={t('Secondary window')} window={secondary} />
+          </div>
+
+          <div className='rounded-lg border'>
+            <div className='flex items-center justify-between gap-2 border-b p-3'>
+              <div className='text-sm font-medium'>{t('Raw JSON')}</div>
+              <div className='flex items-center gap-2'>
+                {onRefresh && (
+                  <Button
+                    type='button'
+                    variant='outline'
+                    size='sm'
+                    onClick={onRefresh}
+                    disabled={Boolean(isRefreshing)}
+                  >
+                    <RefreshCw className='mr-2 h-4 w-4' />
+                    {t('Refresh')}
+                  </Button>
+                )}
+                <Button
+                  type='button'
+                  variant='outline'
+                  size='sm'
+                  onClick={() => copyToClipboard(rawJsonText)}
+                  aria-label={t('Copy to clipboard')}
+                  title={t('Copy to clipboard')}
+                  disabled={!rawJsonText}
+                >
+                  {copiedText === rawJsonText ? (
+                    <Check className='mr-2 h-4 w-4 text-green-600' />
+                  ) : (
+                    <Copy className='mr-2 h-4 w-4' />
+                  )}
+                  {t('Copy')}
+                </Button>
+              </div>
+            </div>
+            <ScrollArea className='max-h-[50vh]'>
+              <pre className='bg-muted/30 m-0 whitespace-pre-wrap break-words p-3 text-xs'>
+                {rawJsonText || '-'}
+              </pre>
+            </ScrollArea>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
+            {t('Close')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 85 - 0
web/src/features/channels/components/drawers/channel-mutate-drawer.tsx

@@ -13,6 +13,8 @@ import {
   Eraser,
   Plus,
   Eye,
+  Link2,
+  RefreshCw,
 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
@@ -69,6 +71,7 @@ import {
   getChannelKey,
   getGroups,
   getPrefillGroups,
+  refreshCodexCredential,
   updateChannel,
 } from '../../api'
 import {
@@ -102,6 +105,7 @@ import {
 import type { Channel } from '../../types'
 import { useChannels } from '../channels-provider'
 import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
+import { CodexOAuthDialog } from '../dialogs/codex-oauth-dialog'
 import {
   MissingModelsConfirmationDialog,
   type MissingModelsAction,
@@ -176,6 +180,9 @@ export function ChannelMutateDrawer({
   const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
   const [channelKey, setChannelKey] = useState<string | null>(null)
   const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
+  const [codexOAuthDialogOpen, setCodexOAuthDialogOpen] = useState(false)
+  const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
+    useState(false)
   const [doubaoApiEditUnlocked, setDoubaoApiEditUnlocked] = useState(false)
   const doubaoApiClickCountRef = useRef(0)
   const initialModelsRef = useRef<string[]>([])
@@ -522,6 +529,23 @@ export function ChannelMutateDrawer({
     }
   }, [channelId, withVerification, fetchChannelKey])
 
+  const handleRefreshCodexCredential = useCallback(async () => {
+    if (!channelId) return
+    setIsCodexCredentialRefreshing(true)
+    try {
+      const res = await refreshCodexCredential(channelId)
+      if (!res.success) {
+        throw new Error(res.message || 'Failed to refresh credential')
+      }
+      toast.success(t('Credential refreshed'))
+      queryClient.invalidateQueries({ queryKey: channelsQueryKeys.detail(channelId) })
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : t('Refresh failed'))
+    } finally {
+      setIsCodexCredentialRefreshing(false)
+    }
+  }, [channelId, queryClient, t])
+
   // Unified function to update models
   const updateModels = useCallback(
     (newModels: string[], merge: boolean = false) => {
@@ -1793,6 +1817,67 @@ export function ChannelMutateDrawer({
                   )}
                 />
 
+                {currentType === 57 && (
+                  <div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
+                    <div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
+                      <div className='space-y-0.5'>
+                        <div className='text-sm font-semibold'>
+                          {t('Codex Authorization')}
+                        </div>
+                        <div className='text-muted-foreground text-xs'>
+                          {t(
+                            'Codex channels use an OAuth JSON credential as the key.'
+                          )}
+                        </div>
+                      </div>
+                      <div className='flex flex-wrap items-center gap-2'>
+                        <Button
+                          type='button'
+                          variant='outline'
+                          size='sm'
+                          onClick={() => setCodexOAuthDialogOpen(true)}
+                        >
+                          <Link2 className='mr-2 h-4 w-4' />
+                          {t('Authorize')}
+                        </Button>
+                        {isEditing && channelId && (
+                          <Button
+                            type='button'
+                            variant='outline'
+                            size='sm'
+                            onClick={handleRefreshCodexCredential}
+                            disabled={isCodexCredentialRefreshing}
+                          >
+                            {isCodexCredentialRefreshing ? (
+                              <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                            ) : (
+                              <RefreshCw className='mr-2 h-4 w-4' />
+                            )}
+                            {isCodexCredentialRefreshing
+                              ? t('Refreshing...')
+                              : t('Refresh credential')}
+                          </Button>
+                        )}
+                      </div>
+                    </div>
+                    <Alert>
+                      <AlertDescription>
+                        {t(
+                          'If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.'
+                        )}
+                      </AlertDescription>
+                    </Alert>
+                  </div>
+                )}
+
+                <CodexOAuthDialog
+                  open={codexOAuthDialogOpen}
+                  onOpenChange={setCodexOAuthDialogOpen}
+                  onKeyGenerated={(key) => {
+                    form.setValue('key', key, { shouldDirty: true })
+                  }}
+                />
+
                 {isEditing && isMultiKeyChannel && (
                   <FormField
                     control={form.control}

+ 4 - 0
web/src/features/channels/constants.ts

@@ -58,6 +58,7 @@ export const CHANNEL_TYPES = {
   54: i18n.t('DoubaoVideo'),
   55: i18n.t('Sora'),
   56: i18n.t('Replicate'),
+  57: i18n.t('Codex'),
 } as const
 
 export const CHANNEL_TYPE_OPTIONS = Object.entries(CHANNEL_TYPES)
@@ -385,6 +386,9 @@ export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
     'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)'
   ),
   51: i18n.t('Format: Access Key ID|Secret Access Key'),
+  57: i18n.t(
+    'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)'
+  ),
 }
 
 // Channel types with special warnings

+ 2 - 10
web/src/features/channels/lib/channel-utils.ts

@@ -7,6 +7,7 @@ import {
   MULTI_KEY_STATUS_CONFIG,
   RESPONSE_TIME_CONFIG,
   RESPONSE_TIME_THRESHOLDS,
+  TYPE_TO_KEY_PROMPT,
 } from '../constants'
 import type { Channel, ChannelSettings, ChannelOtherSettings } from '../types'
 
@@ -625,14 +626,5 @@ export function deduplicateKeys(keysText: string): {
  * Get key prompt based on channel type
  */
 export function getKeyPromptForType(type: number): string {
-  const typePrompts: Record<number, string> = {
-    15: 'Format: APIKey|SecretKey',
-    18: 'Format: APPID|APISecret|APIKey',
-    22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041',
-    23: 'Format: AppId|SecretId|SecretKey',
-    33: 'Format: Ak|Sk|Region',
-    50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
-    51: 'Format: Access Key ID|Secret Access Key',
-  }
-  return typePrompts[type] || 'Enter API key for this channel'
+  return TYPE_TO_KEY_PROMPT[type] || 'Enter API key for this channel'
 }

+ 3 - 0
web/src/features/system-settings/integrations/index.tsx

@@ -20,6 +20,9 @@ const defaultIntegrationSettings: IntegrationSettingsType = {
   AutomaticDisableChannelEnabled: false,
   AutomaticEnableChannelEnabled: false,
   AutomaticDisableKeywords: '',
+  AutomaticDisableStatusCodes: '401',
+  AutomaticRetryStatusCodes:
+    '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
   'monitor_setting.auto_test_channel_enabled': false,
   'monitor_setting.auto_test_channel_minutes': 10,
   'model_deployment.ionet.api_key': '',

+ 119 - 0
web/src/features/system-settings/integrations/monitoring-settings-section.tsx

@@ -17,6 +17,7 @@ import {
 import { Input } from '@/components/ui/input'
 import { Switch } from '@/components/ui/switch'
 import { Textarea } from '@/components/ui/textarea'
+import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
 import { SettingsSection } from '../components/settings-section'
 import { useResetForm } from '../hooks/use-reset-form'
 import { useUpdateOption } from '../hooks/use-update-option'
@@ -33,6 +34,8 @@ const monitoringSchema = z.object({
   AutomaticDisableChannelEnabled: z.boolean(),
   AutomaticEnableChannelEnabled: z.boolean(),
   AutomaticDisableKeywords: z.string(),
+  AutomaticDisableStatusCodes: z.string(),
+  AutomaticRetryStatusCodes: z.string(),
   monitor_setting: z.object({
     auto_test_channel_enabled: z.boolean(),
     auto_test_channel_minutes: z.coerce
@@ -41,6 +44,31 @@ const monitoringSchema = z.object({
       .min(1, 'Interval must be at least 1 minute'),
   }),
 })
+.superRefine((values, ctx) => {
+  const disableParsed = parseHttpStatusCodeRules(
+    values.AutomaticDisableStatusCodes
+  )
+  if (!disableParsed.ok) {
+    ctx.addIssue({
+      code: 'custom',
+      path: ['AutomaticDisableStatusCodes'],
+      message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
+        ', '
+      )}`,
+    })
+  }
+
+  const retryParsed = parseHttpStatusCodeRules(values.AutomaticRetryStatusCodes)
+  if (!retryParsed.ok) {
+    ctx.addIssue({
+      code: 'custom',
+      path: ['AutomaticRetryStatusCodes'],
+      message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
+        ', '
+      )}`,
+    })
+  }
+})
 
 type MonitoringFormValues = z.output<typeof monitoringSchema>
 type MonitoringFormInput = z.input<typeof monitoringSchema>
@@ -52,6 +80,8 @@ type MonitoringSettingsSectionProps = {
     AutomaticDisableChannelEnabled: boolean
     AutomaticEnableChannelEnabled: boolean
     AutomaticDisableKeywords: string
+    AutomaticDisableStatusCodes: string
+    AutomaticRetryStatusCodes: string
     'monitor_setting.auto_test_channel_enabled': boolean
     'monitor_setting.auto_test_channel_minutes': number
   }
@@ -67,6 +97,8 @@ type NormalizedMonitoringValues = {
   AutomaticDisableChannelEnabled: boolean
   AutomaticEnableChannelEnabled: boolean
   AutomaticDisableKeywords: string
+  AutomaticDisableStatusCodes: string
+  AutomaticRetryStatusCodes: string
   'monitor_setting.auto_test_channel_enabled': boolean
   'monitor_setting.auto_test_channel_minutes': number
 }
@@ -81,6 +113,8 @@ const buildFormDefaults = (
   AutomaticDisableKeywords: normalizeLineEndings(
     defaults.AutomaticDisableKeywords ?? ''
   ),
+  AutomaticDisableStatusCodes: defaults.AutomaticDisableStatusCodes ?? '',
+  AutomaticRetryStatusCodes: defaults.AutomaticRetryStatusCodes ?? '',
   monitor_setting: {
     auto_test_channel_enabled:
       defaults['monitor_setting.auto_test_channel_enabled'],
@@ -99,6 +133,12 @@ const normalizeDefaults = (
   AutomaticDisableKeywords: normalizeLineEndings(
     defaults.AutomaticDisableKeywords ?? ''
   ),
+  AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
+    defaults.AutomaticDisableStatusCodes ?? ''
+  ).normalized,
+  AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
+    defaults.AutomaticRetryStatusCodes ?? ''
+  ).normalized,
   'monitor_setting.auto_test_channel_enabled':
     defaults['monitor_setting.auto_test_channel_enabled'],
   'monitor_setting.auto_test_channel_minutes':
@@ -115,6 +155,12 @@ const normalizeFormValues = (
   AutomaticDisableKeywords: normalizeLineEndings(
     values.AutomaticDisableKeywords
   ),
+  AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
+    values.AutomaticDisableStatusCodes
+  ).normalized,
+  AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
+    values.AutomaticRetryStatusCodes
+  ).normalized,
   'monitor_setting.auto_test_channel_enabled':
     values.monitor_setting.auto_test_channel_enabled,
   'monitor_setting.auto_test_channel_minutes':
@@ -142,6 +188,17 @@ export function MonitoringSettingsSection({
 
   useResetForm(form, formDefaults)
 
+  const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes')
+  const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes')
+  const autoDisableParsed = useMemo(
+    () => parseHttpStatusCodeRules(autoDisableStatusCodes),
+    [autoDisableStatusCodes]
+  )
+  const autoRetryParsed = useMemo(
+    () => parseHttpStatusCodeRules(autoRetryStatusCodes),
+    [autoRetryStatusCodes]
+  )
+
   const onSubmit = async (values: MonitoringFormValues) => {
     const normalized = normalizeFormValues(values)
     const updates = (
@@ -353,6 +410,68 @@ export function MonitoringSettingsSection({
             )}
           />
 
+          <div className='grid gap-6 md:grid-cols-2'>
+            <FormField
+              control={form.control}
+              name='AutomaticDisableStatusCodes'
+              render={({ field }) => (
+                <FormItem>
+                  <FormLabel>{t('Auto-disable status codes')}</FormLabel>
+                  <FormControl>
+                    <Input
+                      placeholder={t('e.g. 401, 403, 429, 500-599')}
+                      value={field.value}
+                      onChange={(event) => field.onChange(event.target.value)}
+                    />
+                  </FormControl>
+                  <FormDescription>
+                    {t(
+                      'Accepts comma-separated status codes and inclusive ranges.'
+                    )}{' '}
+                    {autoDisableParsed.ok &&
+                      autoDisableParsed.normalized &&
+                      autoDisableParsed.normalized !== field.value.trim() && (
+                        <span className='text-muted-foreground'>
+                          {t('Normalized:')} {autoDisableParsed.normalized}
+                        </span>
+                      )}
+                  </FormDescription>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+
+            <FormField
+              control={form.control}
+              name='AutomaticRetryStatusCodes'
+              render={({ field }) => (
+                <FormItem>
+                  <FormLabel>{t('Auto-retry status codes')}</FormLabel>
+                  <FormControl>
+                    <Input
+                      placeholder={t('e.g. 401, 403, 429, 500-599')}
+                      value={field.value}
+                      onChange={(event) => field.onChange(event.target.value)}
+                    />
+                  </FormControl>
+                  <FormDescription>
+                    {t(
+                      'Accepts comma-separated status codes and inclusive ranges.'
+                    )}{' '}
+                    {autoRetryParsed.ok &&
+                      autoRetryParsed.normalized &&
+                      autoRetryParsed.normalized !== field.value.trim() && (
+                        <span className='text-muted-foreground'>
+                          {t('Normalized:')} {autoRetryParsed.normalized}
+                        </span>
+                      )}
+                  </FormDescription>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+          </div>
+
           <Button type='submit' disabled={updateOption.isPending}>
             {updateOption.isPending
               ? t('Saving...')

+ 2 - 0
web/src/features/system-settings/integrations/section-registry.tsx

@@ -96,6 +96,8 @@ const INTEGRATIONS_SECTIONS = [
           AutomaticEnableChannelEnabled:
             settings.AutomaticEnableChannelEnabled,
           AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
+          AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
+          AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
           'monitor_setting.auto_test_channel_enabled':
             settings['monitor_setting.auto_test_channel_enabled'],
           'monitor_setting.auto_test_channel_minutes':

+ 192 - 0
web/src/features/system-settings/models/global-settings-card.tsx

@@ -16,12 +16,56 @@ import {
 } from '@/components/ui/form'
 import { Input } from '@/components/ui/input'
 import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Separator } from '@/components/ui/separator'
 import { SettingsSection } from '../components/settings-section'
 import { useUpdateOption } from '../hooks/use-update-option'
 
+const thinkingBlacklistExample = JSON.stringify(
+  ['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
+  null,
+  2
+)
+
+const chatToResponsesPolicyExample = JSON.stringify(
+  {
+    enabled: true,
+    all_channels: false,
+    channel_ids: [1, 2],
+    model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
+  },
+  null,
+  2
+)
+
+const chatToResponsesPolicyAllChannelsExample = JSON.stringify(
+  {
+    enabled: true,
+    all_channels: true,
+    model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
+  },
+  null,
+  2
+)
+
+const jsonString = z.string().refine((value) => {
+  const trimmed = value.trim()
+  if (!trimmed) return true
+  try {
+    JSON.parse(trimmed)
+    return true
+  } catch {
+    return false
+  }
+}, 'Invalid JSON format')
+
 const schema = z.object({
   global: z.object({
     pass_through_request_enabled: z.boolean(),
+    thinking_model_blacklist: jsonString,
+    chat_completions_to_responses_policy: jsonString,
   }),
   general_setting: z.object({
     ping_interval_enabled: z.boolean(),
@@ -34,6 +78,8 @@ type GlobalModelSettingsFormInput = z.input<typeof schema>
 
 type FlatGlobalModelSettings = {
   'global.pass_through_request_enabled': boolean
+  'global.thinking_model_blacklist': string
+  'global.chat_completions_to_responses_policy': string
   'general_setting.ping_interval_enabled': boolean
   'general_setting.ping_interval_seconds': number
 }
@@ -43,12 +89,25 @@ const flattenGlobalValues = (
 ): FlatGlobalModelSettings => ({
   'global.pass_through_request_enabled':
     values.global.pass_through_request_enabled,
+  'global.thinking_model_blacklist': normalizeJsonText(
+    values.global.thinking_model_blacklist,
+    '[]'
+  ),
+  'global.chat_completions_to_responses_policy': normalizeJsonText(
+    values.global.chat_completions_to_responses_policy,
+    '{}'
+  ),
   'general_setting.ping_interval_enabled':
     values.general_setting.ping_interval_enabled,
   'general_setting.ping_interval_seconds':
     values.general_setting.ping_interval_seconds,
 })
 
+function normalizeJsonText(value: string, fallback: string) {
+  const trimmed = (value ?? '').toString().trim()
+  return trimmed ? trimmed : fallback
+}
+
 type GlobalSettingsCardProps = {
   defaultValues: GlobalModelSettingsFormValues
 }
@@ -72,6 +131,21 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
 
   const pingEnabled = form.watch('general_setting.ping_interval_enabled')
 
+  const formatJsonField = (
+    field:
+      | 'global.thinking_model_blacklist'
+      | 'global.chat_completions_to_responses_policy'
+  ) => {
+    const raw = form.getValues(field)
+    if (!raw || !raw.trim()) return
+    try {
+      const formatted = JSON.stringify(JSON.parse(raw), null, 2)
+      form.setValue(field, formatted, { shouldDirty: true })
+    } catch {
+      toast.error(t('Invalid JSON format'))
+    }
+  }
+
   const onSubmit = async (values: GlobalModelSettingsFormValues) => {
     const flattenedDefaults = flattenGlobalValues(defaultValues)
     const flattenedValues = flattenGlobalValues(values)
@@ -127,6 +201,124 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
             )}
           />
 
+          <FormField
+            control={form.control}
+            name='global.thinking_model_blacklist'
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>{t('Disable thinking processing models')}</FormLabel>
+                <FormControl>
+                  <Textarea
+                    rows={4}
+                    placeholder={`${t('Example:')}\n${thinkingBlacklistExample}`}
+                    {...field}
+                    onChange={(event) => field.onChange(event.target.value)}
+                  />
+                </FormControl>
+                <FormDescription>
+                  {t(
+                    'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
+                  )}
+                </FormDescription>
+                <div className='flex flex-wrap gap-2'>
+                  <Button
+                    type='button'
+                    variant='outline'
+                    size='sm'
+                    onClick={() => formatJsonField('global.thinking_model_blacklist')}
+                  >
+                    {t('Format JSON')}
+                  </Button>
+                </div>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+
+          <Separator />
+
+          <div className='space-y-4'>
+            <div className='flex items-center gap-2'>
+                <h3 className='text-base font-semibold'>
+                {t('ChatCompletions -> Responses Compatibility')}
+              </h3>
+              <Badge variant='secondary'>{t('Preview')}</Badge>
+            </div>
+
+            <Alert>
+              <AlertTitle>{t('Warning')}</AlertTitle>
+              <AlertDescription>
+                {t(
+                  'This feature is experimental. Configuration format and behavior may change.'
+                )}
+              </AlertDescription>
+            </Alert>
+
+            <FormField
+              control={form.control}
+              name='global.chat_completions_to_responses_policy'
+              render={({ field }) => (
+                <FormItem>
+                  <FormLabel>{t('Policy JSON')}</FormLabel>
+                  <FormControl>
+                    <Textarea
+                      rows={8}
+                      placeholder={`${t('Example (specific channels):')}\n${chatToResponsesPolicyExample}\n\n${t('Example (all channels):')}\n${chatToResponsesPolicyAllChannelsExample}`}
+                      {...field}
+                      onChange={(event) => field.onChange(event.target.value)}
+                    />
+                  </FormControl>
+                  <FormDescription>
+                    {t('Empty value will be saved as {}.')}
+                  </FormDescription>
+                  <div className='flex flex-wrap gap-2'>
+                    <Button
+                      type='button'
+                      variant='outline'
+                      size='sm'
+                      onClick={() =>
+                        form.setValue(
+                          'global.chat_completions_to_responses_policy',
+                          chatToResponsesPolicyExample,
+                          { shouldDirty: true }
+                        )
+                      }
+                    >
+                      {t('Fill example (specific channels)')}
+                    </Button>
+                    <Button
+                      type='button'
+                      variant='outline'
+                      size='sm'
+                      onClick={() =>
+                        form.setValue(
+                          'global.chat_completions_to_responses_policy',
+                          chatToResponsesPolicyAllChannelsExample,
+                          { shouldDirty: true }
+                        )
+                      }
+                    >
+                      {t('Fill example (all channels)')}
+                    </Button>
+                    <Button
+                      type='button'
+                      variant='outline'
+                      size='sm'
+                      onClick={() =>
+                        formatJsonField('global.chat_completions_to_responses_policy')
+                      }
+                    >
+                      {t('Format JSON')}
+                    </Button>
+                  </div>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+          </div>
+
+          <Separator />
+
           <FormField
             control={form.control}
             name='general_setting.ping_interval_enabled'

+ 2 - 0
web/src/features/system-settings/models/index.tsx

@@ -7,6 +7,8 @@ import {
 
 const defaultModelSettings: ModelSettings = {
   'global.pass_through_request_enabled': false,
+  'global.thinking_model_blacklist': '[]',
+  'global.chat_completions_to_responses_policy': '{}',
   'general_setting.ping_interval_enabled': false,
   'general_setting.ping_interval_seconds': 60,
   'gemini.safety_settings': '',

+ 18 - 0
web/src/features/system-settings/models/section-registry.tsx

@@ -5,6 +5,16 @@ import { GeminiSettingsCard } from './gemini-settings-card'
 import { GlobalSettingsCard } from './global-settings-card'
 import { RatioSettingsCard } from './ratio-settings-card'
 
+function formatJsonForEditor(value: string, fallback: string) {
+  const raw = (value ?? '').toString().trim()
+  if (!raw) return fallback
+  try {
+    return JSON.stringify(JSON.parse(raw), null, 2)
+  } catch {
+    return fallback
+  }
+}
+
 const MODELS_SECTIONS = [
   {
     id: 'global',
@@ -16,6 +26,14 @@ const MODELS_SECTIONS = [
           global: {
             pass_through_request_enabled:
               settings['global.pass_through_request_enabled'],
+            thinking_model_blacklist: formatJsonForEditor(
+              settings['global.thinking_model_blacklist'],
+              '[]'
+            ),
+            chat_completions_to_responses_policy: formatJsonForEditor(
+              settings['global.chat_completions_to_responses_policy'],
+              '{}'
+            ),
           },
           general_setting: {
             ping_interval_enabled:

+ 4 - 0
web/src/features/system-settings/types.ts

@@ -139,6 +139,8 @@ export type IntegrationSettings = {
   AutomaticDisableChannelEnabled: boolean
   AutomaticEnableChannelEnabled: boolean
   AutomaticDisableKeywords: string
+  AutomaticDisableStatusCodes: string
+  AutomaticRetryStatusCodes: string
   'monitor_setting.auto_test_channel_enabled': boolean
   'monitor_setting.auto_test_channel_minutes': number
   'model_deployment.ionet.api_key': string
@@ -166,6 +168,8 @@ export type IntegrationSettings = {
 
 export type ModelSettings = {
   'global.pass_through_request_enabled': boolean
+  'global.thinking_model_blacklist': string
+  'global.chat_completions_to_responses_policy': string
   'general_setting.ping_interval_enabled': boolean
   'general_setting.ping_interval_seconds': number
   'gemini.safety_settings': string

+ 48 - 22
web/src/features/usage-logs/components/columns/common-logs-columns.tsx

@@ -35,16 +35,16 @@ import { useUsageLogsContext } from '../usage-logs-provider'
 import { renderBadge, CacheTooltip } from './column-helpers'
 
 function DetailsCell({
-  content,
-  logType,
+  log,
+  isAdmin,
 }: {
-  content?: string | null
-  logType: number
+  log: UsageLog
+  isAdmin: boolean
 }) {
   const { t } = useTranslation()
   const [dialogOpen, setDialogOpen] = useState(false)
 
-  if (content == null) {
+  if (log.content == null) {
     return <span className='text-muted-foreground text-sm'>-</span>
   }
 
@@ -56,11 +56,49 @@ function DetailsCell({
         onClick={() => setDialogOpen(true)}
         title={t('Click to view full details')}
       >
-        <span className='truncate'>{content}</span>
+        <span className='truncate'>{log.content}</span>
       </Button>
       <DetailsDialog
-        details={content}
-        logType={logType}
+        log={log}
+        isAdmin={isAdmin}
+        open={dialogOpen}
+        onOpenChange={setDialogOpen}
+      />
+    </>
+  )
+}
+
+function BillingTypeCell({
+  log,
+  isAdmin,
+}: {
+  log: UsageLog
+  isAdmin: boolean
+}) {
+  const [dialogOpen, setDialogOpen] = useState(false)
+  const other = parseLogOther(log.other)
+  const isPerCall = isPerCallBilling(other?.model_price)
+
+  return (
+    <>
+      <Button
+        type='button'
+        variant='ghost'
+        size='sm'
+        className='h-auto p-0 hover:bg-transparent'
+        onClick={() => setDialogOpen(true)}
+        title={isPerCall ? 'Per-call' : 'Per-token'}
+      >
+        <StatusBadge
+          label={isPerCall ? 'Per-call' : 'Per-token'}
+          variant={isPerCall ? 'teal' : 'violet'}
+          size='sm'
+          copyable={false}
+        />
+      </Button>
+      <DetailsDialog
+        log={log}
+        isAdmin={isAdmin}
         open={dialogOpen}
         onOpenChange={setDialogOpen}
       />
@@ -494,25 +532,13 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
       header: t('Details'),
       cell: ({ row }) => {
         const log = row.original
-        const content = row.getValue('content') as string | null
 
         // For non-consume logs, show content
         if (log.type !== 2) {
-          return <DetailsCell content={content} logType={log.type} />
+          return <DetailsCell log={log} isAdmin={isAdmin} />
         }
 
-        // For consume logs, show billing type
-        const other = parseLogOther(log.other)
-        const isPerCall = isPerCallBilling(other?.model_price)
-
-        return (
-          <StatusBadge
-            label={isPerCall ? 'Per-call' : 'Per-token'}
-            variant={isPerCall ? 'teal' : 'violet'}
-            size='sm'
-            copyable={false}
-          />
-        )
+        return <BillingTypeCell log={log} isAdmin={isAdmin} />
       },
       meta: { label: t('Details') },
     }

+ 61 - 6
web/src/features/usage-logs/components/dialogs/details-dialog.tsx

@@ -1,4 +1,4 @@
-import { Copy, Check } from 'lucide-react'
+import { Copy, Check, Route } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
 import { Button } from '@/components/ui/button'
@@ -11,22 +11,35 @@ import {
 } from '@/components/ui/dialog'
 import { Label } from '@/components/ui/label'
 import { ScrollArea } from '@/components/ui/scroll-area'
+import type { UsageLog } from '../../data/schema'
+import { parseLogOther } from '../../lib/format'
 
 interface DetailsDialogProps {
-  details: string
-  logType: number
+  log: UsageLog
+  isAdmin: boolean
   open: boolean
   onOpenChange: (open: boolean) => void
 }
 
 export function DetailsDialog({
-  details,
-  logType,
+  log,
+  isAdmin,
   open,
   onOpenChange,
 }: DetailsDialogProps) {
   const { t } = useTranslation()
   const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
+  const details = log.content ?? ''
+  const other = parseLogOther(log.other)
+  const conversionChain =
+    other && Array.isArray(other.request_conversion)
+      ? other.request_conversion.filter(Boolean)
+      : []
+  const conversionLabel =
+    conversionChain.length <= 1
+      ? t('Native format')
+      : conversionChain.join(' -> ')
+  const showConversion = isAdmin && (other?.request_path || conversionChain.length > 0)
 
   // Get log type label
   const getLogTypeLabel = (type: number): string => {
@@ -52,13 +65,54 @@ export function DetailsDialog({
         <DialogHeader>
           <DialogTitle>{t('Log Details')}</DialogTitle>
           <DialogDescription>
-            {t('View the complete details for this')} {getLogTypeLabel(logType)}{' '}
+            {t('View the complete details for this')}{' '}
+            {getLogTypeLabel(log.type)}{' '}
             {t('log')}
           </DialogDescription>
         </DialogHeader>
 
         <ScrollArea className='max-h-[500px] pr-4'>
           <div className='space-y-4 py-4'>
+            {showConversion && (
+              <div className='space-y-2'>
+                <Label className='text-sm font-semibold'>
+                  {t('Request conversion')}
+                </Label>
+                <div className='bg-muted/50 relative rounded-md border p-3'>
+                  <Button
+                    variant='ghost'
+                    size='sm'
+                    className='absolute top-2 right-2 h-8 w-8 p-0'
+                    onClick={() => copyToClipboard(conversionLabel)}
+                    title={t('Copy to clipboard')}
+                    aria-label={t('Copy to clipboard')}
+                  >
+                    {copiedText === conversionLabel ? (
+                      <Check className='size-4 text-green-600' />
+                    ) : (
+                      <Copy className='size-4' />
+                    )}
+                  </Button>
+                  <div className='space-y-2 pr-10'>
+                    {other?.request_path ? (
+                      <div className='text-sm'>
+                        <span className='text-muted-foreground'>
+                          {t('Path:')}{' '}
+                        </span>
+                        <span className='font-mono break-words'>
+                          {other.request_path}
+                        </span>
+                      </div>
+                    ) : null}
+                    <div className='flex items-center gap-2 text-sm'>
+                      <Route className='text-muted-foreground size-4' aria-hidden='true' />
+                      <span className='break-words'>{conversionLabel}</span>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            )}
+
             <div className='space-y-2'>
               <Label className='text-sm font-semibold'>{t('Content')}</Label>
               <div className='bg-muted/50 relative rounded-md border p-3'>
@@ -68,6 +122,7 @@ export function DetailsDialog({
                   className='absolute top-2 right-2 h-8 w-8 p-0'
                   onClick={() => copyToClipboard(details)}
                   title={t('Copy to clipboard')}
+                  aria-label={t('Copy to clipboard')}
                 >
                   {copiedText === details ? (
                     <Check className='size-4 text-green-600' />

+ 2 - 0
web/src/features/usage-logs/types.ts

@@ -68,6 +68,8 @@ export interface LogOtherData {
     use_channel?: number[]
     local_count_tokens?: boolean
   }
+  request_path?: string
+  request_conversion?: string[]
   ws?: boolean
   audio?: boolean
   audio_input?: number

+ 52 - 1
web/src/i18n/locales/en.json

@@ -1,7 +1,59 @@
 {
   "translation": {
+    "360": "360",
     "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"",
     "% off": "% off",
+    "Allowed": "Allowed",
+    "Authorize": "Authorize",
+    "Auto-disable status codes": "Auto-disable status codes",
+    "Auto-retry status codes": "Auto-retry status codes",
+    "Callback URL": "Callback URL",
+    "Channel:": "Channel:",
+    "ChatCompletions -> Responses Compatibility": "ChatCompletions -> Responses Compatibility",
+    "Click to view Codex usage": "Click to view Codex usage",
+    "Codex Authorization": "Codex Authorization",
+    "Codex": "Codex",
+    "Codex Usage": "Codex Usage",
+    "Codex channels use an OAuth JSON credential as the key.": "Codex channels use an OAuth JSON credential as the key.",
+    "Copy authorization link": "Copy authorization link",
+    "Credential generated": "Credential generated",
+    "Credential refreshed": "Credential refreshed",
+    "Disable thinking processing models": "Disable thinking processing models",
+    "Empty value will be saved as {}.": "Empty value will be saved as {}.",
+    "Example (all channels):": "Example (all channels):",
+    "Example (specific channels):": "Example (specific channels):",
+    "Failed to fetch usage": "Failed to fetch usage",
+    "Fill example (all channels)": "Fill example (all channels)",
+    "Fill example (specific channels)": "Fill example (specific channels)",
+    "Format JSON": "Format JSON",
+    "Generate a Codex OAuth credential and paste it into the channel key field.": "Generate a Codex OAuth credential and paste it into the channel key field.",
+    "Generate credential": "Generate credential",
+    "Generating...": "Generating...",
+    "If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.": "If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.",
+    "Limited": "Limited",
+    "Native format": "Native format",
+    "Normalized:": "Normalized:",
+    "OAuth start failed": "OAuth start failed",
+    "Open authorization page": "Open authorization page",
+    "Opened authorization page": "Opened authorization page",
+    "Paste the full callback URL (includes code & state)": "Paste the full callback URL (includes code & state)",
+    "Path:": "Path:",
+    "Please manually copy and open the authorization link": "Please manually copy and open the authorization link",
+    "Policy JSON": "Policy JSON",
+    "Primary window": "Primary window",
+    "Refresh credential": "Refresh credential",
+    "Refresh failed": "Refresh failed",
+    "Refreshing...": "Refreshing...",
+    "Request conversion": "Request conversion",
+    "Reset at:": "Reset at:",
+    "Resets in:": "Resets in:",
+    "Secondary window": "Secondary window",
+    "Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.": "Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.",
+    "Upstream status:": "Upstream status:",
+    "Warning": "Warning",
+    "Window:": "Window:",
+    "e.g. 401, 403, 429, 500-599": "e.g. 401, 403, 429, 500-599",
+    "1) Click \"Open authorization page\" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click \"Generate credential\".": "1) Click \"Open authorization page\" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click \"Generate credential\".",
     "(Leave empty to dissolve tag)": "(Leave empty to dissolve tag)",
     "(Optional: redirect model names)": "(Optional: redirect model names)",
     "(Override all channels' groups)": "(Override all channels' groups)",
@@ -30,7 +82,6 @@
     "2. Copy the application token": "2. Copy the application token",
     "20 / page": "20 / page",
     "3. Enter your Gotify server URL and token above": "3. Enter your Gotify server URL and token above",
-    "360": "360",
     "50 / page": "50 / page",
     "? This action cannot be undone.": "? This action cannot be undone.",
     "@lobehub/icons key": "@lobehub/icons key",

+ 115 - 0
web/src/lib/http-status-code-rules.ts

@@ -0,0 +1,115 @@
+export type StatusCodeRange = {
+  start: number
+  end: number
+}
+
+export type ParsedHttpStatusCodeRules = {
+  ok: boolean
+  ranges: StatusCodeRange[]
+  tokens: string[]
+  normalized: string
+  invalidTokens: string[]
+}
+
+export function parseHttpStatusCodeRules(
+  input: unknown
+): ParsedHttpStatusCodeRules {
+  const raw = (input ?? '').toString().trim()
+  if (raw.length === 0) {
+    return {
+      ok: true,
+      ranges: [],
+      tokens: [],
+      normalized: '',
+      invalidTokens: [],
+    }
+  }
+
+  const sanitized = raw.replace(/[,]/g, ',')
+  const segments = sanitized.split(',').map((s) => s.trim()).filter(Boolean)
+
+  const ranges: StatusCodeRange[] = []
+  const invalidTokens: string[] = []
+
+  for (const segment of segments) {
+    const parsed = parseToken(segment)
+    if (!parsed) {
+      invalidTokens.push(segment)
+    } else {
+      ranges.push(parsed)
+    }
+  }
+
+  if (invalidTokens.length > 0) {
+    return {
+      ok: false,
+      ranges: [],
+      tokens: [],
+      normalized: raw,
+      invalidTokens,
+    }
+  }
+
+  const merged = mergeRanges(ranges)
+  const tokens = merged.map((r) =>
+    r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`
+  )
+  const normalized = tokens.join(',')
+
+  return {
+    ok: true,
+    ranges: merged,
+    tokens,
+    normalized,
+    invalidTokens: [],
+  }
+}
+
+function parseToken(token: string): StatusCodeRange | null {
+  const cleaned = token.trim().replace(/\s/g, '')
+  if (!cleaned) return null
+
+  const isValidCode = (code: number) =>
+    Number.isFinite(code) && code >= 100 && code <= 599
+
+  if (cleaned.includes('-')) {
+    const [a, b] = cleaned.split('-')
+    if (!isNumber(a) || !isNumber(b)) return null
+
+    const start = Number.parseInt(a, 10)
+    const end = Number.parseInt(b, 10)
+    if (!isValidCode(start) || !isValidCode(end) || start > end) return null
+
+    return { start, end }
+  }
+
+  if (!isNumber(cleaned)) return null
+  const code = Number.parseInt(cleaned, 10)
+  if (!isValidCode(code)) return null
+
+  return { start: code, end: code }
+}
+
+function isNumber(s: string) {
+  return typeof s === 'string' && /^\d+$/.test(s)
+}
+
+function mergeRanges(ranges: StatusCodeRange[]): StatusCodeRange[] {
+  if (ranges.length === 0) return []
+
+  const sorted = [...ranges].sort((a, b) =>
+    a.start !== b.start ? a.start - b.start : a.end - b.end
+  )
+
+  return sorted.reduce<StatusCodeRange[]>((merged, current) => {
+    const last = merged[merged.length - 1]
+
+    if (!last || current.start > last.end + 1) {
+      merged.push({ ...current })
+    } else {
+      last.end = Math.max(last.end, current.end)
+    }
+
+    return merged
+  }, [])
+}

+ 15 - 0
web/src/lib/utils.ts

@@ -78,3 +78,18 @@ export function truncateText(text: string, maxLength: number): string {
   if (!text || text.length <= maxLength) return text
   return text.slice(0, maxLength) + '...'
 }
+
+/**
+ * Try to parse and pretty-print JSON, fallback to original text if invalid
+ * @param text - Text that might be JSON
+ * @returns Pretty-printed JSON or original text
+ */
+export function tryPrettyJson(text: string): string {
+  const raw = (text ?? '').toString().trim()
+  if (!raw) return ''
+  try {
+    return JSON.stringify(JSON.parse(raw), null, 2)
+  } catch {
+    return raw
+  }
+}