Просмотр исходного кода

feat(ui): redesign model square pricing page

CaIon 1 неделя назад
Родитель
Сommit
d2b30dfc95
34 измененных файлов с 2464 добавлено и 763 удалено
  1. 397 125
      web/default/src/features/home/components/hero-terminal-demo.tsx
  2. 3 1
      web/default/src/features/home/components/sections/cta.tsx
  3. 4 4
      web/default/src/features/home/components/sections/features.tsx
  4. 1 1
      web/default/src/features/home/components/sections/hero.tsx
  5. 1 1
      web/default/src/features/home/components/sections/how-it-works.tsx
  6. 12 5
      web/default/src/features/home/components/sections/stats.tsx
  7. 13 13
      web/default/src/features/home/constants.ts
  8. 15 10
      web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx
  9. 0 2
      web/default/src/features/pricing/components/filter-bar.tsx
  10. 5 3
      web/default/src/features/pricing/components/index.ts
  11. 36 30
      web/default/src/features/pricing/components/loading-skeleton.tsx
  12. 92 0
      web/default/src/features/pricing/components/model-card-grid.tsx
  13. 221 0
      web/default/src/features/pricing/components/model-card.tsx
  14. 374 62
      web/default/src/features/pricing/components/model-details.tsx
  15. 0 274
      web/default/src/features/pricing/components/model-row.tsx
  16. 97 0
      web/default/src/features/pricing/components/pricing-columns.tsx
  17. 289 0
      web/default/src/features/pricing/components/pricing-sidebar.tsx
  18. 4 8
      web/default/src/features/pricing/components/pricing-table.tsx
  19. 296 0
      web/default/src/features/pricing/components/pricing-toolbar.tsx
  20. 3 3
      web/default/src/features/pricing/components/search-bar.tsx
  21. 0 70
      web/default/src/features/pricing/components/virtual-model-list.tsx
  22. 1 1
      web/default/src/features/pricing/constants.ts
  23. 77 51
      web/default/src/features/pricing/hooks/use-filters.ts
  24. 177 85
      web/default/src/features/pricing/index.tsx
  25. 169 0
      web/default/src/features/pricing/lib/dynamic-price.ts
  26. 1 1
      web/default/src/hooks/use-top-nav-links.ts
  27. 29 2
      web/default/src/i18n/locales/en.json
  28. 29 2
      web/default/src/i18n/locales/fr.json
  29. 29 2
      web/default/src/i18n/locales/ja.json
  30. 29 2
      web/default/src/i18n/locales/ru.json
  31. 29 2
      web/default/src/i18n/locales/vi.json
  32. 29 2
      web/default/src/i18n/locales/zh.json
  33. 1 0
      web/default/src/routes/pricing/$modelId/index.tsx
  34. 1 1
      web/default/src/routes/pricing/index.tsx

+ 397 - 125
web/default/src/features/home/components/hero-terminal-demo.tsx

@@ -1,51 +1,77 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, type ReactNode } from 'react'
 import { cn } from '@/lib/utils'
 
-interface ModelConfig {
+interface ApiDemoConfig {
   id: string
-  name: string
+  label: string
+  endpoint: string
+  requestBodyLines: string[]
+  responseKind: 'chat' | 'responses' | 'claude' | 'gemini'
   response: string
   tokens: number
   latency: number
   badgeClass: string
 }
 
-const MODELS: ModelConfig[] = [
+const API_DEMOS: ApiDemoConfig[] = [
   {
-    id: 'gpt-4o',
-    name: 'gpt-4o',
-    response:
-      'Artificial intelligence models can be seamlessly accessed through a unified API gateway, enabling developers to switch between providers effortlessly.',
+    id: 'gpt-chat',
+    label: 'GPT Chat',
+    endpoint: '/v1/chat/completions',
+    requestBodyLines: [
+      '"model": "your-model",',
+      '"messages": [',
+      '  { "role": "user", "content": "..." }',
+      ']',
+    ],
+    responseKind: 'chat',
+    response: 'Route chat requests through configured upstreams.',
     tokens: 27,
     latency: 142,
     badgeClass:
       'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:bg-emerald-500/15 dark:text-emerald-400 dark:ring-emerald-500/25',
   },
   {
-    id: 'claude-sonnet',
-    name: 'claude-sonnet-4-20250514',
-    response:
-      'A unified gateway abstracts away provider differences, letting you focus on building great products while we handle routing, failover, and cost optimization.',
+    id: 'responses',
+    label: 'Responses',
+    endpoint: '/v1/responses',
+    requestBodyLines: ['"model": "your-model",', '"input": "..."'],
+    responseKind: 'responses',
+    response: 'Run response workflows behind one gateway.',
     tokens: 31,
     latency: 168,
     badgeClass:
       'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:bg-amber-500/15 dark:text-amber-400 dark:ring-amber-500/25',
   },
   {
-    id: 'gemini-pro',
-    name: 'gemini-2.5-pro',
-    response:
-      'By consolidating multiple AI providers behind one endpoint, teams can reduce integration complexity and gain unified observability across all model usage.',
+    id: 'claude',
+    label: 'Claude',
+    endpoint: '/v1/messages',
+    requestBodyLines: [
+      '"model": "your-model",',
+      '"max_tokens": 1024,',
+      '"messages": [',
+      '  { "role": "user", "content": "..." }',
+      ']',
+    ],
+    responseKind: 'claude',
+    response: 'Send Claude-style messages through your gateway.',
     tokens: 29,
     latency: 156,
     badgeClass:
       'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:bg-blue-500/15 dark:text-blue-400 dark:ring-blue-500/25',
   },
   {
-    id: 'deepseek',
-    name: 'deepseek-chat',
-    response:
-      'An API gateway provides automatic load balancing, rate limiting, and cost tracking — essential infrastructure for production AI applications at scale.',
+    id: 'gemini',
+    label: 'Gemini',
+    endpoint: '/v1beta/models/{model}:generateContent',
+    requestBodyLines: [
+      '"contents": [',
+      '  { "parts": [{ "text": "..." }] }',
+      ']',
+    ],
+    responseKind: 'gemini',
+    response: 'Serve Gemini-compatible generation requests.',
     tokens: 25,
     latency: 93,
     badgeClass:
@@ -67,7 +93,7 @@ export function HeroTerminalDemo() {
     intervalRef.current = setInterval(() => {
       setTransitioning(true)
       setTimeout(() => {
-        setActiveIndex((prev) => (prev + 1) % MODELS.length)
+        setActiveIndex((prev) => (prev + 1) % API_DEMOS.length)
         setTransitioning(false)
       }, 300)
     }, CYCLE_INTERVAL)
@@ -75,7 +101,7 @@ export function HeroTerminalDemo() {
     return () => clearInterval(intervalRef.current)
   }, [])
 
-  const model = MODELS[activeIndex]
+  const demo = API_DEMOS[activeIndex]
 
   return (
     <div className='mx-auto mt-16 w-full max-w-2xl'>
@@ -101,7 +127,7 @@ export function HeroTerminalDemo() {
           </div>
           <div className='flex items-center gap-2'>
             <ModelSelector
-              models={MODELS}
+              demos={API_DEMOS}
               activeIndex={activeIndex}
               onSelect={(i) => {
                 clearInterval(intervalRef.current)
@@ -134,40 +160,7 @@ export function HeroTerminalDemo() {
                 Request
               </span>
             </div>
-            <div className='text-foreground/80'>
-              <span className='text-emerald-600 dark:text-emerald-400'>
-                curl
-              </span>{' '}
-              <span className='text-blue-600 dark:text-blue-400'>-X POST</span>{' '}
-              <span className='text-amber-600 dark:text-amber-400'>
-                &quot;/v1/chat/completions&quot;
-              </span>
-              {' \\\n'}
-              <span className='text-foreground/15'>{'  '}</span>
-              <span className='text-blue-600 dark:text-blue-400'>-H</span>{' '}
-              <span className='text-amber-600 dark:text-amber-400'>
-                &quot;Authorization: Bearer sk-••••&quot;
-              </span>
-              {' \\\n'}
-              <span className='text-foreground/15'>{'  '}</span>
-              <span className='text-blue-600 dark:text-blue-400'>-d</span>{' '}
-              <span className='text-amber-600 dark:text-amber-400'>
-                {'\'{"model": "'}
-              </span>
-              <span
-                className={cn(
-                  'transition-all duration-300',
-                  transitioning
-                    ? 'text-foreground/20'
-                    : 'text-amber-700 dark:text-amber-300'
-                )}
-              >
-                {model.name}
-              </span>
-              <span className='text-amber-600 dark:text-amber-400'>
-                {'", "messages": [...]}\''}
-              </span>
-            </div>
+            <RequestPreview demo={demo} transitioning={transitioning} />
           </div>
 
           {/* Response */}
@@ -183,7 +176,7 @@ export function HeroTerminalDemo() {
                     transitioning ? 'opacity-0' : 'opacity-100'
                   )}
                 >
-                  {model.latency}ms
+                  {demo.latency}ms
                 </span>
               </div>
               <div
@@ -192,70 +185,11 @@ export function HeroTerminalDemo() {
                   transitioning ? 'opacity-0' : 'opacity-100'
                 )}
               >
-                <span>{model.tokens} tokens</span>
-                <span>${(model.tokens * 0.00003).toFixed(5)}</span>
-              </div>
-            </div>
-            <div
-              className={cn(
-                'rounded-lg border px-3.5 py-3',
-                'border-border/40 bg-muted/30',
-                'dark:border-white/[0.06] dark:bg-white/[0.02]'
-              )}
-            >
-              <div className='text-foreground/35'>{'{'}</div>
-              <div className='pl-4'>
-                <span className='text-blue-600 dark:text-blue-400'>
-                  &quot;model&quot;
-                </span>
-                <span className='text-foreground/25'>: </span>
-                <span
-                  className={cn(
-                    'transition-all duration-300',
-                    transitioning
-                      ? 'text-foreground/15'
-                      : 'text-amber-600 dark:text-amber-400'
-                  )}
-                >
-                  &quot;{model.name}&quot;
-                </span>
-                <span className='text-foreground/25'>,</span>
-              </div>
-              <div className='pl-4'>
-                <span className='text-blue-600 dark:text-blue-400'>
-                  &quot;content&quot;
-                </span>
-                <span className='text-foreground/25'>: </span>
-                <span
-                  className={cn(
-                    'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
-                    transitioning ? 'opacity-0' : 'opacity-100'
-                  )}
-                >
-                  &quot;{model.response}&quot;
-                </span>
-              </div>
-              <div className='pl-4'>
-                <span className='text-blue-600 dark:text-blue-400'>
-                  &quot;usage&quot;
-                </span>
-                <span className='text-foreground/25'>: {'{'} </span>
-                <span className='text-blue-600 dark:text-blue-400'>
-                  &quot;total_tokens&quot;
-                </span>
-                <span className='text-foreground/25'>: </span>
-                <span
-                  className={cn(
-                    'text-violet-600 transition-all duration-300 dark:text-violet-400',
-                    transitioning ? 'opacity-0' : 'opacity-100'
-                  )}
-                >
-                  {model.tokens}
-                </span>
-                <span className='text-foreground/25'> {'}'}</span>
+                <span>{demo.tokens} tokens</span>
+                <span>${(demo.tokens * 0.00003).toFixed(5)}</span>
               </div>
-              <div className='text-foreground/35'>{'}'}</div>
             </div>
+            <ResponsePreview demo={demo} transitioning={transitioning} />
           </div>
         </div>
       </div>
@@ -263,25 +197,363 @@ export function HeroTerminalDemo() {
   )
 }
 
+function RequestPreview(props: {
+  demo: ApiDemoConfig
+  transitioning: boolean
+}) {
+  const { demo, transitioning } = props
+
+  return (
+    <div className='space-y-0.5 text-foreground/80'>
+      <CodeLine>
+        <Command>curl</Command> <Flag>-X POST</Flag>{' '}
+        <AnimatedString transitioning={transitioning}>
+          &quot;{demo.endpoint}&quot;
+        </AnimatedString>{' '}
+        <Muted>{'\\'}</Muted>
+      </CodeLine>
+      <CodeLine indent={2}>
+        <Flag>-H</Flag>{' '}
+        <StringText>&quot;Authorization: Bearer sk-••••&quot;</StringText>{' '}
+        <Muted>{'\\'}</Muted>
+      </CodeLine>
+      <CodeLine indent={2}>
+        <Flag>-d</Flag> <StringText>&apos;{'{'}</StringText>
+      </CodeLine>
+      {demo.requestBodyLines.map((line) => (
+        <CodeLine key={line} indent={4}>
+          <AnimatedString transitioning={transitioning}>
+            {line}
+          </AnimatedString>
+        </CodeLine>
+      ))}
+      <CodeLine indent={2}>
+        <StringText>{'}'}&apos;</StringText>
+      </CodeLine>
+    </div>
+  )
+}
+
+function ResponsePreview(props: {
+  demo: ApiDemoConfig
+  transitioning: boolean
+}) {
+  const { demo, transitioning } = props
+
+  return (
+    <div
+      className={cn(
+        'rounded-lg border px-3.5 py-3',
+        'border-border/40 bg-muted/30',
+        'dark:border-white/[0.06] dark:bg-white/[0.02]'
+      )}
+    >
+      {demo.responseKind === 'chat' && (
+        <>
+          <CodeLine>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Key>&quot;choices&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'{'} </Muted>
+            <Key>&quot;message&quot;</Key>
+            <Muted>: {'{'} </Muted>
+            <Key>&quot;content&quot;</Key>
+            <Muted>: </Muted>
+            <ResponseText demo={demo} transitioning={transitioning} />
+            <Muted> {'}'} {'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Muted>],</Muted>
+          </CodeLine>
+          <UsageLine
+            container='usage'
+            name='total_tokens'
+            value={demo.tokens}
+            indent={2}
+          />
+          <CodeLine>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+        </>
+      )}
+
+      {demo.responseKind === 'responses' && (
+        <>
+          <CodeLine>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Key>&quot;output&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={6}>
+            <Key>&quot;type&quot;</Key>
+            <Muted>: </Muted>
+            <StringText>&quot;message&quot;</StringText>
+            <Muted>,</Muted>
+          </CodeLine>
+          <CodeLine indent={6}>
+            <Key>&quot;content&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={8}>
+            <Muted>{'{'} </Muted>
+            <Key>&quot;type&quot;</Key>
+            <Muted>: </Muted>
+            <StringText>&quot;output_text&quot;</StringText>
+            <Muted>, </Muted>
+            <Key>&quot;text&quot;</Key>
+            <Muted>: </Muted>
+            <ResponseText demo={demo} transitioning={transitioning} />
+            <Muted> {'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={6}>
+            <Muted>]</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Muted>],</Muted>
+          </CodeLine>
+          <UsageLine
+            container='usage'
+            name='total_tokens'
+            value={demo.tokens}
+            indent={2}
+          />
+          <CodeLine>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+        </>
+      )}
+
+      {demo.responseKind === 'claude' && (
+        <>
+          <CodeLine>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Key>&quot;content&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'{'} </Muted>
+            <Key>&quot;type&quot;</Key>
+            <Muted>: </Muted>
+            <StringText>&quot;text&quot;</StringText>
+            <Muted>, </Muted>
+            <Key>&quot;text&quot;</Key>
+            <Muted>: </Muted>
+            <ResponseText demo={demo} transitioning={transitioning} />
+            <Muted> {'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Muted>],</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Key>&quot;usage&quot;</Key>
+            <Muted>: {'{'} </Muted>
+            <Key>&quot;input_tokens&quot;</Key>
+            <Muted>: </Muted>
+            <NumberText>{Math.floor(demo.tokens * 0.4)}</NumberText>
+            <Muted>, </Muted>
+            <Key>&quot;output_tokens&quot;</Key>
+            <Muted>: </Muted>
+            <NumberText>{Math.ceil(demo.tokens * 0.6)}</NumberText>
+            <Muted> {'}'}</Muted>
+          </CodeLine>
+          <CodeLine>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+        </>
+      )}
+
+      {demo.responseKind === 'gemini' && (
+        <>
+          <CodeLine>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Key>&quot;candidates&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={6}>
+            <Key>&quot;content&quot;</Key>
+            <Muted>: {'{'}</Muted>
+          </CodeLine>
+          <CodeLine indent={8}>
+            <Key>&quot;parts&quot;</Key>
+            <Muted>: [</Muted>
+          </CodeLine>
+          <CodeLine indent={10}>
+            <Muted>{'{'} </Muted>
+            <Key>&quot;text&quot;</Key>
+            <Muted>: </Muted>
+            <ResponseText demo={demo} transitioning={transitioning} />
+            <Muted> {'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={8}>
+            <Muted>]</Muted>
+          </CodeLine>
+          <CodeLine indent={6}>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={4}>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+          <CodeLine indent={2}>
+            <Muted>],</Muted>
+          </CodeLine>
+          <UsageLine
+            container='usageMetadata'
+            name='totalTokenCount'
+            value={demo.tokens}
+            indent={2}
+          />
+          <CodeLine>
+            <Muted>{'}'}</Muted>
+          </CodeLine>
+        </>
+      )}
+    </div>
+  )
+}
+
+function UsageLine(props: {
+  container: string
+  name: string
+  value: number
+  indent: number
+}) {
+  return (
+    <CodeLine indent={props.indent}>
+      <Key>&quot;{props.container}&quot;</Key>
+      <Muted>: {'{'} </Muted>
+      <Key>&quot;{props.name}&quot;</Key>
+      <Muted>: </Muted>
+      <NumberText>{props.value}</NumberText>
+      <Muted> {'}'}</Muted>
+    </CodeLine>
+  )
+}
+
+function ResponseText(props: {
+  demo: ApiDemoConfig
+  transitioning: boolean
+}) {
+  return (
+    <span
+      className={cn(
+        'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
+        props.transitioning ? 'opacity-0' : 'opacity-100'
+      )}
+    >
+      &quot;{props.demo.response}&quot;
+    </span>
+  )
+}
+
+function CodeLine(props: { children: ReactNode; indent?: number }) {
+  return (
+    <div className='whitespace-pre-wrap break-words'>
+      {props.indent ? (
+        <span
+          aria-hidden
+          className='inline-block'
+          style={{ width: `${props.indent}ch` }}
+        />
+      ) : null}
+      {props.children}
+    </div>
+  )
+}
+
+function AnimatedString(props: {
+  children: ReactNode
+  transitioning: boolean
+}) {
+  return (
+    <span
+      className={cn(
+        'transition-all duration-300',
+        props.transitioning
+          ? 'text-foreground/20'
+          : 'text-amber-700 dark:text-amber-300'
+      )}
+    >
+      {props.children}
+    </span>
+  )
+}
+
+function Command(props: { children: ReactNode }) {
+  return (
+    <span className='text-emerald-600 dark:text-emerald-400'>
+      {props.children}
+    </span>
+  )
+}
+
+function Flag(props: { children: ReactNode }) {
+  return (
+    <span className='text-blue-600 dark:text-blue-400'>{props.children}</span>
+  )
+}
+
+function Key(props: { children: ReactNode }) {
+  return (
+    <span className='text-blue-600 dark:text-blue-400'>{props.children}</span>
+  )
+}
+
+function StringText(props: { children: ReactNode }) {
+  return (
+    <span className='text-amber-600 dark:text-amber-400'>{props.children}</span>
+  )
+}
+
+function NumberText(props: { children: ReactNode }) {
+  return (
+    <span className='text-violet-600 dark:text-violet-400'>
+      {props.children}
+    </span>
+  )
+}
+
+function Muted(props: { children: ReactNode }) {
+  return <span className='text-foreground/35'>{props.children}</span>
+}
+
 function ModelSelector(props: {
-  models: ModelConfig[]
+  demos: ApiDemoConfig[]
   activeIndex: number
   onSelect: (index: number) => void
 }) {
   return (
     <div className='flex items-center gap-1'>
-      {props.models.map((m, i) => (
+      {props.demos.map((demo, i) => (
         <button
-          key={m.id}
+          key={demo.id}
           onClick={() => props.onSelect(i)}
           className={cn(
             'rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 transition-all duration-300 ring-inset',
             i === props.activeIndex
-              ? m.badgeClass
+              ? demo.badgeClass
               : 'text-foreground/20 ring-border/30 hover:text-foreground/40 hover:ring-border/50 dark:ring-white/[0.06] dark:hover:ring-white/10'
           )}
         >
-          {m.id}
+          {demo.label}
         </button>
       ))}
     </div>

+ 3 - 1
web/default/src/features/home/components/sections/cta.tsx

@@ -42,7 +42,9 @@ export function CTA(props: CTAProps) {
           </span>
         </h2>
         <p className='text-muted-foreground/80 mx-auto mt-5 max-w-md text-sm leading-relaxed md:text-base'>
-          {t('Start for free with generous limits. No credit card required.')}
+          {t(
+            'Deploy your own gateway and start routing requests through your configured upstream services.'
+          )}
         </p>
         <div className='mt-8 flex items-center justify-center gap-3'>
           <Button className='group rounded-lg' asChild>

+ 4 - 4
web/default/src/features/home/components/sections/features.tsx

@@ -113,7 +113,7 @@ export function Features(_props: FeaturesProps) {
       id: 'developer',
       num: '04',
       title: t('Developer Friendly'),
-      desc: t('Complete API documentation with multi-language SDK support'),
+      desc: t('Compatible API routes for common AI application workflows'),
       span: 'md:col-span-2',
       icon: <Code className='size-4 text-amber-400' />,
       visual: (
@@ -130,7 +130,7 @@ export function Features(_props: FeaturesProps) {
           </div>
           <div className='text-muted-foreground flex items-center gap-1.5 text-xs'>
             <Code className='size-3.5 text-blue-500' />
-            {t('OpenAI Compatible')}
+            {t('Multi-protocol Compatible')}
           </div>
         </div>
       ),
@@ -155,8 +155,8 @@ export function Features(_props: FeaturesProps) {
     },
     {
       icon: <HeartHandshake className='size-5' strokeWidth={1.5} />,
-      title: t('Technical Support'),
-      desc: t('Professional team providing 24/7 technical support'),
+      title: t('Open Source'),
+      desc: t('Community driven, self-hosted, and extensible'),
     },
   ]
 

+ 1 - 1
web/default/src/features/home/components/sections/hero.tsx

@@ -51,7 +51,7 @@ export function Hero(props: HeroProps) {
         >
           {systemName}{' '}
           {t(
-            'aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.'
+            'is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.'
           )}
         </p>
         <div

+ 1 - 1
web/default/src/features/home/components/sections/how-it-works.tsx

@@ -18,7 +18,7 @@ export function HowItWorks() {
       num: '2',
       title: t('Connect'),
       desc: t(
-        'Use our unified OpenAI-compatible endpoint in your applications'
+        'Connect through OpenAI, Claude, Gemini, and other compatible API routes'
       ),
       icon: <Zap className='size-6' strokeWidth={1.5} />,
     },

+ 12 - 5
web/default/src/features/home/components/sections/stats.tsx

@@ -69,14 +69,21 @@ interface StatsProps {
   className?: string
 }
 
+interface StatItem {
+  end: number
+  suffix: string
+  label: string
+  decimals?: number
+}
+
 export function Stats(_props: StatsProps) {
   const { t } = useTranslation()
 
-  const stats = [
-    { end: 100, suffix: 'M+', label: t('requests served') },
-    { end: 50, suffix: '+', label: t('AI models supported') },
-    { end: 99.9, suffix: '%', label: t('uptime'), decimals: 1 },
-    { end: 10, suffix: 'K+', label: t('active users') },
+  const stats: StatItem[] = [
+    { end: 50, suffix: '+', label: t('upstream services integrated') },
+    { end: 100, suffix: '+', label: t('model billing support') },
+    { end: 50, suffix: '+', label: t('compatible API routes') },
+    { end: 10, suffix: '+', label: t('scheduling controls') },
   ]
 
   return (

+ 13 - 13
web/default/src/features/home/constants.ts

@@ -42,24 +42,24 @@ export const GATEWAY_FEATURES = [
 // Stats section - Default statistics
 export const DEFAULT_STATS = [
   {
-    value: '100',
-    suffix: 'M+',
-    description: 'requests served',
+    value: '50',
+    suffix: '+',
+    description: 'upstream services integrated',
   },
   {
-    value: '50',
+    value: '100',
     suffix: '+',
-    description: 'AI models supported',
+    description: 'model billing support',
   },
   {
-    value: '99.9',
-    suffix: '%',
-    description: 'uptime',
+    value: '50',
+    suffix: '+',
+    description: 'compatible API routes',
   },
   {
     value: '10',
-    suffix: 'K+',
-    description: 'active users',
+    suffix: '+',
+    description: 'scheduling controls',
   },
 ] as const
 
@@ -84,7 +84,7 @@ export const DEFAULT_FEATURES = [
   },
   {
     title: 'Developer Friendly',
-    description: 'Complete API documentation with multi-language SDK support',
+    description: 'Compatible API routes for common AI application workflows',
     iconName: 'Code',
   },
   {
@@ -103,8 +103,8 @@ export const DEFAULT_FEATURES = [
     iconName: 'Users',
   },
   {
-    title: 'Technical Support',
-    description: 'Professional team providing 24/7 technical support',
+    title: 'Open Source',
+    description: 'Community driven, self-hosted, and extensible',
     iconName: 'HeartHandshake',
   },
 ] as const

+ 15 - 10
web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx

@@ -156,14 +156,11 @@ export function DynamicPricingBreakdown({
     return { symbol: '$', rate: 1 }
   }, [currency])
 
-  const priceSuffix = `${symbol}/1M tokens`
-
-  const { tiers, ruleGroups, baseExpr } = useMemo(() => {
+  const { tiers, ruleGroups } = useMemo(() => {
     const split = splitBillingExprAndRequestRules(expr)
     const parsedTiers = parseTiersFromExpr(split.billingExpr)
     const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
     return {
-      baseExpr: split.billingExpr,
       tiers: parsedTiers,
       ruleGroups: parsedRules || [],
     }
@@ -174,19 +171,27 @@ export function DynamicPricingBreakdown({
 
   if (!expr) return null
 
-  if (!hasTiers && !hasRules) {
+  if (!hasTiers) {
     return (
       <section className='min-w-0 py-4'>
         <div className='mb-3 flex items-center gap-2'>
           <span className='inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
             <TagIcon className='size-3.5' />
           </span>
-          <span className='text-foreground text-base font-medium'>
-            {t('Dynamic Pricing')}
-          </span>
+          <div>
+            <div className='text-foreground text-base font-medium'>
+              {t('Special billing expression')}
+            </div>
+            <div className='text-muted-foreground text-xs'>
+              {t('Unable to parse structured pricing')}
+            </div>
+          </div>
+        </div>
+        <div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
+          {t('Raw expression')}
         </div>
         <code className='text-muted-foreground block text-xs break-all'>
-          {baseExpr || expr}
+          {expr}
         </code>
       </section>
     )
@@ -233,7 +238,7 @@ export function DynamicPricingBreakdown({
                       key={v.field}
                       className='text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase'
                     >
-                      {`${t(v.shortLabel)} (${priceSuffix})`}
+                      {t(v.shortLabel)}
                     </TableHead>
                   ))}
                 </TableRow>

+ 0 - 2
web/default/src/features/pricing/components/filter-bar.tsx

@@ -4,7 +4,6 @@ import {
   Check,
   ChevronDown,
   Filter,
-  List,
   RotateCcw,
   Table2,
   X,
@@ -572,7 +571,6 @@ export function FilterBar(props: FilterBarProps) {
 
           <SegmentedControl
             options={[
-              { value: VIEW_MODES.LIST, icon: List, tooltip: t('List view') },
               {
                 value: VIEW_MODES.TABLE,
                 icon: Table2,

+ 5 - 3
web/default/src/features/pricing/components/index.ts

@@ -1,8 +1,10 @@
 export { FilterBar } from './filter-bar'
-export { ModelRow } from './model-row'
+export { PricingSidebar } from './pricing-sidebar'
+export { PricingToolbar } from './pricing-toolbar'
+export { ModelCard } from './model-card'
+export { ModelCardGrid } from './model-card-grid'
 export { LoadingSkeleton } from './loading-skeleton'
 export { EmptyState } from './empty-state'
 export { SearchBar } from './search-bar'
-export { ModelDetails } from './model-details'
-export { VirtualModelList } from './virtual-model-list'
+export { ModelDetails, ModelDetailsDrawer } from './model-details'
 export { PricingTable } from './pricing-table'

+ 36 - 30
web/default/src/features/pricing/components/loading-skeleton.tsx

@@ -6,7 +6,7 @@ export interface LoadingSkeletonProps {
 }
 
 export function LoadingSkeleton(props: LoadingSkeletonProps) {
-  const viewMode = props.viewMode ?? VIEW_MODES.LIST
+  const viewMode = props.viewMode ?? VIEW_MODES.CARD
 
   return (
     <div className='space-y-5'>
@@ -19,12 +19,46 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) {
       {viewMode === VIEW_MODES.TABLE ? (
         <TableContentSkeleton />
       ) : (
-        <ListContentSkeleton />
+        <CardContentSkeleton />
       )}
     </div>
   )
 }
 
+function CardContentSkeleton() {
+  return (
+    <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
+      {Array.from({ length: 9 }).map((_, i) => (
+        <div key={i} className='rounded-xl border p-5'>
+          <div className='flex items-start justify-between gap-3'>
+            <div className='flex min-w-0 items-start gap-3'>
+              <Skeleton className='size-10 shrink-0 rounded-xl' />
+              <div className='min-w-0 flex-1 space-y-2'>
+                <Skeleton className='h-5 w-36' />
+                <Skeleton className='h-3.5 w-48' />
+              </div>
+            </div>
+            <Skeleton className='h-8 w-16 rounded-md' />
+          </div>
+          <div className='mt-4 space-y-2'>
+            <Skeleton className='h-3.5 w-full' />
+            <Skeleton className='h-3.5 w-4/5' />
+          </div>
+          <div className='mt-4 flex items-center gap-2'>
+            <Skeleton className='h-4 w-24' />
+            <Skeleton className='h-4 w-16' />
+          </div>
+          <div className='mt-2 flex items-center gap-3'>
+            <Skeleton className='h-3.5 w-14' />
+            <Skeleton className='h-3.5 w-14' />
+            <Skeleton className='h-3.5 w-8' />
+          </div>
+        </div>
+      ))}
+    </div>
+  )
+}
+
 function FilterBarSkeleton() {
   return (
     <div className='space-y-3'>
@@ -50,34 +84,6 @@ function FilterBarSkeleton() {
   )
 }
 
-function ListContentSkeleton() {
-  return (
-    <div className='overflow-hidden rounded-lg border'>
-      {Array.from({ length: 8 }).map((_, i) => (
-        <div
-          key={i}
-          className='flex items-start gap-4 border-b px-4 py-3.5 last:border-b-0 sm:px-5 sm:py-4'
-        >
-          <Skeleton className='hidden size-5 shrink-0 rounded sm:block' />
-          <div className='min-w-0 flex-1 space-y-2'>
-            <Skeleton className='h-5 w-48' />
-            <div className='flex items-center gap-2'>
-              <Skeleton className='h-3.5 w-20' />
-              <Skeleton className='h-3.5 w-24' />
-            </div>
-            <Skeleton className='h-3.5 w-full max-w-md' />
-          </div>
-          <div className='shrink-0 space-y-1 text-right'>
-            <Skeleton className='ml-auto h-4 w-20' />
-            <Skeleton className='ml-auto h-4 w-16' />
-            <Skeleton className='ml-auto h-4 w-20' />
-          </div>
-        </div>
-      ))}
-    </div>
-  )
-}
-
 function TableContentSkeleton() {
   const columns = [
     { width: 200 },

+ 92 - 0
web/default/src/features/pricing/components/model-card-grid.tsx

@@ -0,0 +1,92 @@
+import { useEffect, useMemo, useState } from 'react'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
+import type { PricingModel, TokenUnit } from '../types'
+import { ModelCard } from './model-card'
+
+export interface ModelCardGridProps {
+  models: PricingModel[]
+  onModelClick: (modelName: string) => void
+  priceRate?: number
+  usdExchangeRate?: number
+  tokenUnit?: TokenUnit
+  showRechargePrice?: boolean
+}
+
+export function ModelCardGrid(props: ModelCardGridProps) {
+  const { t } = useTranslation()
+  const [page, setPage] = useState(1)
+  const pageSize = DEFAULT_PRICING_PAGE_SIZE
+  const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
+  const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
+
+  useEffect(() => {
+    setPage(1)
+  }, [props.models])
+
+  const pagedModels = useMemo(() => {
+    const start = (page - 1) * pageSize
+    return props.models.slice(start, start + pageSize)
+  }, [page, pageSize, props.models])
+
+  if (props.models.length === 0) {
+    return null
+  }
+
+  return (
+    <div className='space-y-4 sm:space-y-5'>
+      <div className='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'>
+        {pagedModels.map((model) => (
+          <ModelCard
+            key={model.id ?? model.model_name}
+            model={model}
+            tokenUnit={tokenUnit}
+            priceRate={props.priceRate}
+            usdExchangeRate={props.usdExchangeRate}
+            showRechargePrice={props.showRechargePrice}
+            onClick={() => props.onModelClick(model.model_name || '')}
+          />
+        ))}
+      </div>
+
+      {totalPages > 1 && (
+        <div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-sm sm:flex-row'>
+          <p className='text-muted-foreground'>
+            {t('Page {{current}} of {{total}}', {
+              current: page,
+              total: totalPages,
+            })}
+          </p>
+          <div className='flex items-center gap-2'>
+            <Button
+              type='button'
+              variant='outline'
+              size='sm'
+              onClick={() => setPage((current) => Math.max(1, current - 1))}
+              disabled={page <= 1}
+              className='gap-1.5'
+            >
+              <ChevronLeft className='size-4' />
+              {t('Previous')}
+            </Button>
+            <Button
+              type='button'
+              variant='outline'
+              size='sm'
+              onClick={() =>
+                setPage((current) => Math.min(totalPages, current + 1))
+              }
+              disabled={page >= totalPages}
+              className='gap-1.5'
+            >
+              {t('Next')}
+              <ChevronRight className='size-4' />
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 221 - 0
web/default/src/features/pricing/components/model-card.tsx

@@ -0,0 +1,221 @@
+import { memo } from 'react'
+import { ChevronRight, Copy } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { getLobeIcon } from '@/lib/lobe-icon'
+import { cn } from '@/lib/utils'
+import { StatusBadge } from '@/components/status-badge'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+import { DEFAULT_TOKEN_UNIT } from '../constants'
+import { parseTags } from '../lib/filters'
+import { isTokenBasedModel } from '../lib/model-helpers'
+import { formatPrice, formatRequestPrice } from '../lib/price'
+import {
+  getDynamicDisplayGroupRatio,
+  getDynamicPricingSummary,
+} from '../lib/dynamic-price'
+import type { PricingModel, TokenUnit } from '../types'
+
+export interface ModelCardProps {
+  model: PricingModel
+  onClick: () => void
+  priceRate?: number
+  usdExchangeRate?: number
+  tokenUnit?: TokenUnit
+  showRechargePrice?: boolean
+}
+
+export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
+  const { t } = useTranslation()
+  const { copyToClipboard } = useCopyToClipboard()
+  const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
+  const priceRate = props.priceRate ?? 1
+  const usdExchangeRate = props.usdExchangeRate ?? 1
+  const showRechargePrice = props.showRechargePrice ?? false
+  const isTokenBased = isTokenBasedModel(props.model)
+  const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
+  const tags = parseTags(props.model.tags)
+  const groups = props.model.enable_groups || []
+  const endpoints = props.model.supported_endpoint_types || []
+  const vendorIcon = props.model.vendor_icon
+    ? getLobeIcon(props.model.vendor_icon, 28)
+    : null
+  const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
+  const isDynamicPricing =
+    props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr)
+  const hasCachedPrice = isTokenBased && props.model.cache_ratio != null
+  const dynamicSummary = isDynamicPricing
+    ? getDynamicPricingSummary(props.model, {
+        tokenUnit,
+        showRechargePrice,
+        priceRate,
+        usdExchangeRate,
+        groupRatioMultiplier: getDynamicDisplayGroupRatio(props.model),
+      })
+    : null
+
+  const primaryGroup = groups[0]
+  const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)]
+  const hiddenCount =
+    Math.max(groups.length - 1, 0) +
+    Math.max(endpoints.length - 2, 0) +
+    Math.max(tags.length - 2, 0)
+
+  const handleCopy = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    copyToClipboard(props.model.model_name || '')
+  }
+
+  return (
+    <div
+      className={cn(
+        'group flex flex-col rounded-xl border p-4 transition-colors sm:p-5',
+        'hover:bg-muted/20'
+      )}
+    >
+      {/* Header: icon + name + price + actions */}
+      <div className='flex items-start justify-between gap-2.5 sm:gap-3'>
+        <div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
+          <div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
+            {vendorIcon || (
+              <span className='text-muted-foreground text-sm font-bold'>
+                {initial}
+              </span>
+            )}
+          </div>
+          <div className='min-w-0'>
+            <h3 className='text-foreground truncate font-mono text-[15px] font-bold leading-tight'>
+              {props.model.model_name}
+            </h3>
+            <div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs sm:mt-1 sm:gap-x-3'>
+              {dynamicSummary ? (
+                dynamicSummary.isSpecialExpression ? (
+                  <span className='min-w-0'>
+                    <span className='text-amber-700 dark:text-amber-300'>
+                      {t('Special billing expression')}
+                    </span>
+                    <code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block break-all font-mono text-[11px]'>
+                      {dynamicSummary.rawExpression}
+                    </code>
+                  </span>
+                ) : dynamicSummary.primaryEntries.length > 0 ? (
+                  <>
+                    {dynamicSummary.primaryEntries.map((entry) => (
+                      <span
+                        key={entry.key}
+                        className='text-muted-foreground whitespace-nowrap'
+                      >
+                        {t(entry.shortLabel)}{' '}
+                        <span className='text-foreground font-mono font-semibold'>
+                          {entry.formatted}
+                        </span>
+                        /{tokenUnitLabel}
+                      </span>
+                    ))}
+                  </>
+                ) : (
+                  <span className='text-muted-foreground text-xs'>
+                    {t('Dynamic Pricing')}
+                  </span>
+                )
+              ) : isTokenBased ? (
+                <>
+                  <span className='text-muted-foreground whitespace-nowrap'>
+                    {t('Input')}{' '}
+                    <span className='text-foreground font-mono font-semibold'>
+                      {formatPrice(props.model, 'input', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
+                    </span>
+                    /{tokenUnitLabel}
+                  </span>
+                  <span className='text-muted-foreground whitespace-nowrap'>
+                    {t('Output')}{' '}
+                    <span className='text-foreground font-mono font-semibold'>
+                      {formatPrice(props.model, 'output', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
+                    </span>
+                    /{tokenUnitLabel}
+                  </span>
+                  {hasCachedPrice && (
+                    <span className='text-muted-foreground/60 whitespace-nowrap'>
+                      {t('Cached')}{' '}
+                      <span className='font-mono'>
+                        {formatPrice(props.model, 'cache', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
+                      </span>
+                    </span>
+                  )}
+                </>
+              ) : (
+                <span className='text-muted-foreground whitespace-nowrap'>
+                  <span className='text-foreground font-mono font-semibold'>
+                    {formatRequestPrice(props.model, showRechargePrice, priceRate, usdExchangeRate)}
+                  </span>
+                  {' '}/ {t('request')}
+                </span>
+              )}
+            </div>
+          </div>
+        </div>
+
+        <div className='flex shrink-0 items-center gap-1.5'>
+          <button
+            type='button'
+            onClick={props.onClick}
+            className='text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors sm:px-2.5 sm:py-1.5'
+          >
+            {t('Details')}
+            <ChevronRight className='size-3.5' />
+          </button>
+          <button
+            type='button'
+            onClick={handleCopy}
+            className='text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border p-1.5 transition-colors'
+            title={t('Copy')}
+          >
+            <Copy className='size-3.5' />
+          </button>
+        </div>
+      </div>
+
+      {/* Description */}
+      <p className='text-muted-foreground mt-3 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
+        {props.model.description || t('No description available.')}
+      </p>
+
+      {/* Footer row 1: group + billing type */}
+      <div className='mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 sm:mt-4'>
+        {primaryGroup && (
+          <span className='text-muted-foreground text-xs font-medium'>
+            {primaryGroup} {t('Groups')}
+          </span>
+        )}
+        <span className='text-muted-foreground text-xs font-medium'>
+          {isTokenBased ? t('Token-based') : t('Per Request')}
+        </span>
+        {isDynamicPricing && (
+          <StatusBadge
+            label={t('Dynamic Pricing')}
+            variant='warning'
+            copyable={false}
+            size='sm'
+          />
+        )}
+      </div>
+
+      {/* Footer row 2: endpoint + tag chips */}
+      <div className='mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:mt-2 sm:gap-x-3 sm:gap-y-1'>
+        {bottomTags.map((item) => (
+          <span
+            key={item}
+            className='text-muted-foreground/70 text-xs'
+          >
+            {item}
+          </span>
+        ))}
+        <span className='text-muted-foreground/50 text-xs'>{tokenUnitLabel}</span>
+        {hiddenCount > 0 && (
+          <span className='text-muted-foreground/40 text-xs'>
+            +{hiddenCount}
+          </span>
+        )}
+      </div>
+    </div>
+  )
+})

+ 374 - 62
web/default/src/features/pricing/components/model-details.tsx

@@ -13,6 +13,13 @@ import {
   TableHeader,
   TableRow,
 } from '@/components/ui/table'
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
 import { CopyButton } from '@/components/copy-button'
 import { GroupBadge } from '@/components/group-badge'
 import { PublicLayout } from '@/components/layout'
@@ -24,6 +31,12 @@ import {
   replaceModelInPath,
   isTokenBasedModel,
 } from '../lib/model-helpers'
+import {
+  getDynamicPriceEntries,
+  getDynamicPricingSummary,
+  getDynamicPricingTiers,
+  isDynamicPricingModel,
+} from '../lib/dynamic-price'
 import { formatGroupPrice, formatFixedPrice } from '../lib/price'
 import type { PricingModel, TokenUnit, PriceType } from '../types'
 import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
@@ -44,6 +57,10 @@ function ModelHeader(props: { model: PricingModel }) {
     : null
   const description = model.description || model.vendor_description || null
   const tags = parseTags(model.tags)
+  const isSpecialExpression =
+    model.billing_mode === 'tiered_expr' &&
+    Boolean(model.billing_expr) &&
+    getDynamicPricingTiers(model).length === 0
 
   return (
     <header className='pb-5'>
@@ -75,7 +92,9 @@ function ModelHeader(props: { model: PricingModel }) {
           <>
             <span className='text-muted-foreground/30'>·</span>
             <span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
-              {t('Dynamic Pricing')}
+              {isSpecialExpression
+                ? t('Special billing expression')
+                : t('Dynamic Pricing')}
             </span>
           </>
         )}
@@ -107,7 +126,6 @@ function PriceSection(props: {
   usdExchangeRate: number
   tokenUnit: TokenUnit
   showRechargePrice: boolean
-  groupRatio: Record<string, number>
 }) {
   const { t } = useTranslation()
   const {
@@ -116,23 +134,33 @@ function PriceSection(props: {
     usdExchangeRate,
     tokenUnit,
     showRechargePrice,
-    groupRatio,
   } = props
   const isTokenBased = isTokenBasedModel(model)
   const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
-  const defaultGroup = model.enable_groups?.[0] || ''
-  const ratio = defaultGroup ? groupRatio[defaultGroup] || 1 : 1
-  const groupKey = defaultGroup || '_default'
-  const groupRatioMap = { [groupKey]: ratio }
+  const baseGroupKey = '_base'
+  const baseGroupRatioMap = { [baseGroupKey]: 1 }
+  const dynamicSummary = getDynamicPricingSummary(model, {
+    tokenUnit,
+    showRechargePrice,
+    priceRate,
+    usdExchangeRate,
+    groupRatioMultiplier: 1,
+  })
 
-  const priceTypes: { label: string; type: PriceType; available: boolean }[] = [
-    { label: t('Input'), type: 'input', available: true },
+  const primaryPriceTypes: { label: string; type: PriceType }[] = [
+    { label: t('Input'), type: 'input' },
+    { label: t('Output'), type: 'output' },
+  ]
+  const secondaryPriceTypes: {
+    label: string
+    type: PriceType
+    available: boolean
+  }[] = [
     {
       label: t('Cached input'),
       type: 'cache',
       available: model.cache_ratio != null,
     },
-    { label: t('Output'), type: 'output', available: true },
     {
       label: t('Cache write'),
       type: 'create_cache',
@@ -156,24 +184,97 @@ function PriceSection(props: {
     },
   ]
 
+  if (dynamicSummary) {
+    if (dynamicSummary.isSpecialExpression) {
+      return (
+        <section className='border-b py-4'>
+          <SectionTitle>{t('Base Price')}</SectionTitle>
+          <div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
+            <div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
+              {t('Special billing expression')}
+            </div>
+            <p className='text-muted-foreground mt-1 text-xs'>
+              {t('Unable to parse structured pricing')}
+            </p>
+            <div className='mt-3'>
+              <div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
+                {t('Raw expression')}
+              </div>
+              <code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
+                {dynamicSummary.rawExpression}
+              </code>
+            </div>
+          </div>
+        </section>
+      )
+    }
+
+    return (
+      <section className='border-b py-4'>
+        <SectionTitle>{t('Base Price')}</SectionTitle>
+        {dynamicSummary.primaryEntries.length > 0 ? (
+          <div className='grid grid-cols-2 gap-2'>
+            {dynamicSummary.primaryEntries.map((entry) => (
+              <div key={entry.key} className='rounded-lg border bg-muted/20 p-3'>
+                <div className='text-muted-foreground text-xs'>
+                  {t(entry.shortLabel)}
+                </div>
+                <div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
+                  {entry.formatted}
+                  <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
+                    / {tokenUnitLabel}
+                  </span>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <p className='text-muted-foreground text-sm'>
+            {t('Dynamic Pricing')}
+          </p>
+        )}
+        {dynamicSummary.secondaryEntries.length > 0 && (
+          <div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
+            <div className='space-y-1.5'>
+              {dynamicSummary.secondaryEntries.map((entry) => (
+                <div
+                  key={entry.key}
+                  className='flex items-baseline justify-between gap-4'
+                >
+                  <span className='text-muted-foreground/70 text-sm'>
+                    {t(entry.shortLabel)}
+                  </span>
+                  <span className='text-muted-foreground font-mono text-sm tabular-nums'>
+                    {entry.formatted}
+                    <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
+                      / {tokenUnitLabel}
+                    </span>
+                  </span>
+                </div>
+              ))}
+            </div>
+          </div>
+        )}
+      </section>
+    )
+  }
+
   if (!isTokenBased) {
     return (
       <section className='border-b py-4'>
-        <SectionTitle>{t('Price')}</SectionTitle>
+        <SectionTitle>{t('Base Price')}</SectionTitle>
         <div className='flex items-baseline justify-between'>
           <span className='text-muted-foreground text-sm'>
             {t('Per request')}
           </span>
           <span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
-            {formatGroupPrice(
+            {formatFixedPrice(
               model,
-              groupKey,
-              'input',
-              tokenUnit,
+              baseGroupKey,
               showRechargePrice,
               priceRate,
               usdExchangeRate,
-              groupRatioMap
+              baseGroupRatioMap
             )}
           </span>
         </div>
@@ -181,33 +282,57 @@ function PriceSection(props: {
     )
   }
 
-  const items = priceTypes.filter((p) => p.available)
+  const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
+  const renderPrice = (type: PriceType) => (
+    <>
+      {formatGroupPrice(
+        model,
+        baseGroupKey,
+        type,
+        tokenUnit,
+        showRechargePrice,
+        priceRate,
+        usdExchangeRate,
+        baseGroupRatioMap
+      )}
+      <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
+        / {tokenUnitLabel}
+      </span>
+    </>
+  )
 
   return (
     <section className='border-b py-4'>
-      <SectionTitle>{t('Price')}</SectionTitle>
-      <div className='space-y-1.5'>
-        {items.map((item) => (
-          <div key={item.type} className='flex items-baseline justify-between'>
-            <span className='text-muted-foreground text-sm'>{item.label}</span>
-            <span className='text-foreground font-mono text-sm tabular-nums'>
-              {formatGroupPrice(
-                model,
-                groupKey,
-                item.type,
-                tokenUnit,
-                showRechargePrice,
-                priceRate,
-                usdExchangeRate,
-                groupRatioMap
-              )}
-              <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
-                / {tokenUnitLabel}
-              </span>
-            </span>
+      <SectionTitle>{t('Base Price')}</SectionTitle>
+      <div className='grid grid-cols-2 gap-2'>
+        {primaryPriceTypes.map((item) => (
+          <div key={item.type} className='rounded-lg border bg-muted/20 p-3'>
+            <div className='text-muted-foreground text-xs'>{item.label}</div>
+            <div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
+              {renderPrice(item.type)}
+            </div>
           </div>
         ))}
       </div>
+      {secondaryItems.length > 0 && (
+        <div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
+          <div className='space-y-1.5'>
+            {secondaryItems.map((item) => (
+              <div
+                key={item.type}
+                className='flex items-baseline justify-between gap-4'
+              >
+                <span className='text-muted-foreground/70 text-sm'>
+                  {item.label}
+                </span>
+                <span className='text-muted-foreground font-mono text-sm tabular-nums'>
+                  {renderPrice(item.type)}
+                </span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
     </section>
   )
 }
@@ -348,6 +473,128 @@ function GroupPricingSection(props: {
   const thClass =
     'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
 
+  if (isDynamicPricingModel(model)) {
+    const dynamicTiers = getDynamicPricingTiers(model)
+
+    if (dynamicTiers.length === 0) {
+      return (
+        <section className='py-4'>
+          <SectionTitle>{t('Pricing by Group')}</SectionTitle>
+          <AutoGroupChain model={model} autoGroups={autoGroups} />
+          <div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
+            <div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
+              {t('Special billing expression')}
+            </div>
+            <p className='text-muted-foreground mt-1 text-xs'>
+              {t(
+                'Group prices cannot be expanded because this expression is not a standard tiered pricing expression.'
+              )}
+            </p>
+            <div className='mt-3'>
+              <div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
+                {t('Raw expression')}
+              </div>
+              <code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
+                {model.billing_expr}
+              </code>
+            </div>
+          </div>
+        </section>
+      )
+    }
+
+    const priceFields = Array.from(
+      new Map(
+        dynamicTiers
+          .flatMap((tier) =>
+            getDynamicPriceEntries(tier, {
+              tokenUnit,
+              showRechargePrice,
+              priceRate,
+              usdExchangeRate,
+              groupRatioMultiplier: 1,
+            })
+          )
+          .map((entry) => [entry.field, entry])
+      ).values()
+    )
+
+    return (
+      <section className='py-4'>
+        <SectionTitle>{t('Pricing by Group')}</SectionTitle>
+        <AutoGroupChain model={model} autoGroups={autoGroups} />
+        <div className='space-y-3'>
+          {availableGroups.map((group) => {
+            const ratio = groupRatio[group] || 1
+            return (
+              <div key={group} className='overflow-hidden rounded-lg border'>
+                <div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
+                  <GroupBadge group={group} size='sm' />
+                  <span className='text-muted-foreground font-mono text-xs'>
+                    {ratio}x
+                  </span>
+                </div>
+                <div className='overflow-x-auto'>
+                  <Table className='text-sm'>
+                    <TableHeader>
+                      <TableRow className='hover:bg-transparent'>
+                        <TableHead className={thClass}>{t('Tier')}</TableHead>
+                        {priceFields.map((entry) => (
+                          <TableHead
+                            key={entry.field}
+                            className={`${thClass} text-right`}
+                          >
+                            {t(entry.shortLabel)}
+                          </TableHead>
+                        ))}
+                      </TableRow>
+                    </TableHeader>
+                    <TableBody>
+                      {dynamicTiers.map((tier, tierIndex) => {
+                        const entries = getDynamicPriceEntries(tier, {
+                          tokenUnit,
+                          showRechargePrice,
+                          priceRate,
+                          usdExchangeRate,
+                          groupRatioMultiplier: ratio,
+                        })
+                        const entryMap = new Map(
+                          entries.map((entry) => [entry.field, entry])
+                        )
+
+                        return (
+                          <TableRow key={`${group}-${tier.label || tierIndex}`}>
+                            <TableCell className='text-muted-foreground py-2.5 text-xs'>
+                              {tier.label || t('Default')}
+                            </TableCell>
+                            {priceFields.map((fieldEntry) => {
+                              const entry = entryMap.get(fieldEntry.field)
+                              return (
+                                <TableCell
+                                  key={fieldEntry.field}
+                                  className='py-2.5 text-right font-mono'
+                                >
+                                  {entry?.formatted ?? '-'}
+                                </TableCell>
+                              )
+                            })}
+                          </TableRow>
+                        )
+                      })}
+                    </TableBody>
+                  </Table>
+                </div>
+              </div>
+            )
+          })}
+          <p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
+            {t('Prices shown per')} {tokenUnitLabel} tokens
+          </p>
+        </div>
+      </section>
+    )
+  }
+
   return (
     <section className='py-4'>
       <SectionTitle>{t('Pricing by Group')}</SectionTitle>
@@ -464,6 +711,92 @@ function GroupPricingSection(props: {
   )
 }
 
+export interface ModelDetailsContentProps {
+  model: PricingModel
+  groupRatio: Record<string, number>
+  usableGroup: Record<string, { desc: string; ratio: number }>
+  endpointMap: Record<string, { path?: string; method?: string }>
+  autoGroups: string[]
+  priceRate: number
+  usdExchangeRate: number
+  tokenUnit: TokenUnit
+  showRechargePrice?: boolean
+}
+
+export function ModelDetailsContent(props: ModelDetailsContentProps) {
+  const {
+    model,
+    groupRatio,
+    usableGroup,
+    endpointMap,
+    autoGroups,
+    priceRate,
+    usdExchangeRate,
+    tokenUnit,
+    showRechargePrice = false,
+  } = props
+
+  return (
+    <>
+      <ModelHeader model={model} />
+
+      <PriceSection
+        model={model}
+        priceRate={priceRate}
+        usdExchangeRate={usdExchangeRate}
+        tokenUnit={tokenUnit}
+        showRechargePrice={showRechargePrice}
+      />
+
+      <EndpointsSection model={model} endpointMap={endpointMap} />
+
+      {model.billing_mode === 'tiered_expr' && model.billing_expr && (
+        <div className='border-b'>
+          <DynamicPricingBreakdown billingExpr={model.billing_expr} />
+        </div>
+      )}
+
+      <GroupPricingSection
+        model={model}
+        groupRatio={groupRatio}
+        usableGroup={usableGroup}
+        autoGroups={autoGroups}
+        priceRate={priceRate}
+        usdExchangeRate={usdExchangeRate}
+        tokenUnit={tokenUnit}
+        showRechargePrice={showRechargePrice}
+      />
+    </>
+  )
+}
+
+export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}
+
+export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
+  const { t } = useTranslation()
+  const { open, onOpenChange, ...contentProps } = props
+
+  return (
+    <Sheet open={open} onOpenChange={onOpenChange}>
+      <SheetContent
+        side='right'
+        className='flex w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
+      >
+        <SheetHeader className='sr-only'>
+          <SheetTitle>{props.model.model_name}</SheetTitle>
+          <SheetDescription>{t('Model details')}</SheetDescription>
+        </SheetHeader>
+        <div className='flex-1 overflow-y-auto px-5 pt-12 pb-6 sm:px-6'>
+          <ModelDetailsContent {...contentProps} />
+        </div>
+      </SheetContent>
+    </Sheet>
+  )
+}
+
 export function ModelDetails() {
   const { t } = useTranslation()
   const { modelId } = useParams({ from: '/pricing/$modelId/' })
@@ -547,19 +880,15 @@ export function ModelDetails() {
           {t('Back')}
         </Button>
 
-        <ModelHeader model={model} />
-
-        <PriceSection
+        <ModelDetailsContent
           model={model}
+          groupRatio={groupRatio || {}}
+          usableGroup={usableGroup || {}}
+          autoGroups={autoGroups || []}
           priceRate={priceRate ?? 1}
           usdExchangeRate={usdExchangeRate ?? 1}
           tokenUnit={tokenUnit}
           showRechargePrice={search.rechargePrice ?? false}
-          groupRatio={groupRatio || {}}
-        />
-
-        <EndpointsSection
-          model={model}
           endpointMap={
             (endpointMap as Record<
               string,
@@ -567,23 +896,6 @@ export function ModelDetails() {
             >) || {}
           }
         />
-
-        {model.billing_mode === 'tiered_expr' && model.billing_expr && (
-          <div className='border-b'>
-            <DynamicPricingBreakdown billingExpr={model.billing_expr} />
-          </div>
-        )}
-
-        <GroupPricingSection
-          model={model}
-          groupRatio={groupRatio || {}}
-          usableGroup={usableGroup || {}}
-          autoGroups={autoGroups || []}
-          priceRate={priceRate ?? 1}
-          usdExchangeRate={usdExchangeRate ?? 1}
-          tokenUnit={tokenUnit}
-          showRechargePrice={search.rechargePrice ?? false}
-        />
       </div>
     </PublicLayout>
   )

+ 0 - 274
web/default/src/features/pricing/components/model-row.tsx

@@ -1,274 +0,0 @@
-import { memo, useMemo } from 'react'
-import { ChevronRight } from 'lucide-react'
-import { useTranslation } from 'react-i18next'
-import { getLobeIcon } from '@/lib/lobe-icon'
-import { cn } from '@/lib/utils'
-import { DEFAULT_TOKEN_UNIT } from '../constants'
-import {
-  parseTiersFromExpr,
-  splitBillingExprAndRequestRules,
-  tryParseRequestRuleExpr,
-  SOURCE_TIME,
-} from '../lib/billing-expr'
-import { parseTags } from '../lib/filters'
-import { isTokenBasedModel } from '../lib/model-helpers'
-import { formatPrice, formatRequestPrice } from '../lib/price'
-import type { PricingModel, TokenUnit } from '../types'
-
-export interface ModelRowProps {
-  model: PricingModel
-  onClick: () => void
-  priceRate?: number
-  usdExchangeRate?: number
-  tokenUnit?: TokenUnit
-  showRechargePrice?: boolean
-}
-
-interface DynamicPricingHints {
-  tierCount: number
-  hasTimeCondition: boolean
-  hasRequestCondition: boolean
-}
-
-/**
- * Extract at-a-glance hints from a tiered billing expression.
- *
- * The full breakdown lives in `DynamicPricingBreakdown`; here we only need a
- * minimal summary (tier count + condition presence) so that users scanning
- * the list can tell *what kind* of dynamic pricing applies before clicking
- * through to the model details page.
- */
-function summarizeTieredExpr(
-  expr: string | null | undefined
-): DynamicPricingHints {
-  if (!expr) {
-    return { tierCount: 0, hasTimeCondition: false, hasRequestCondition: false }
-  }
-  const split = splitBillingExprAndRequestRules(expr)
-  const tiers = parseTiersFromExpr(split.billingExpr)
-  const ruleGroups = tryParseRequestRuleExpr(split.requestRuleExpr || '') || []
-
-  let hasTimeCondition = false
-  let hasRequestCondition = false
-  for (const group of ruleGroups) {
-    for (const condition of group.conditions) {
-      if (condition.source === SOURCE_TIME) {
-        hasTimeCondition = true
-      } else {
-        hasRequestCondition = true
-      }
-    }
-  }
-
-  return {
-    tierCount: tiers.length,
-    hasTimeCondition,
-    hasRequestCondition,
-  }
-}
-
-function PriceLabel(props: { label: string; value: string; muted?: boolean }) {
-  return (
-    <div className='flex items-baseline justify-end gap-2'>
-      <span
-        className={cn(
-          'text-[11px]',
-          props.muted ? 'text-muted-foreground/40' : 'text-muted-foreground/60'
-        )}
-      >
-        {props.label}
-      </span>
-      <span
-        className={cn(
-          'font-mono text-sm tabular-nums',
-          props.muted
-            ? 'text-muted-foreground'
-            : 'text-foreground font-semibold'
-        )}
-      >
-        {props.value}
-      </span>
-    </div>
-  )
-}
-
-export const ModelRow = memo(function ModelRow(props: ModelRowProps) {
-  const { t } = useTranslation()
-  const model = props.model
-  const priceRate = props.priceRate ?? 1
-  const usdExchangeRate = props.usdExchangeRate ?? 1
-  const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
-  const showRechargePrice = props.showRechargePrice ?? false
-
-  const isTokenBased = isTokenBasedModel(model)
-  const vendorIcon = model.vendor_icon
-    ? getLobeIcon(model.vendor_icon, 20)
-    : null
-  const tags = parseTags(model.tags)
-  const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
-  const hasCachedPrice = isTokenBased && model.cache_ratio != null
-
-  const isDynamicPricing =
-    model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr)
-  const dynamicHints = useMemo(
-    () => (isDynamicPricing ? summarizeTieredExpr(model.billing_expr) : null),
-    [isDynamicPricing, model.billing_expr]
-  )
-
-  return (
-    <button
-      type='button'
-      onClick={props.onClick}
-      className='group hover:bg-muted/40 w-full border-b text-left transition-colors last:border-b-0'
-    >
-      <div className='flex items-start gap-3.5 px-4 py-3.5 sm:gap-4 sm:px-5 sm:py-4'>
-        <div className='hidden shrink-0 pt-0.5 sm:block'>
-          {vendorIcon || (
-            <div className='bg-muted text-muted-foreground flex size-5 items-center justify-center rounded text-[10px] font-bold'>
-              {model.model_name?.charAt(0).toUpperCase() || '?'}
-            </div>
-          )}
-        </div>
-
-        <div className='min-w-0 flex-1'>
-          <div className='flex items-center gap-2'>
-            <span className='shrink-0 sm:hidden'>{vendorIcon}</span>
-            <h3 className='text-foreground truncate font-mono text-sm font-semibold'>
-              {model.model_name}
-            </h3>
-          </div>
-
-          <div className='text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs'>
-            {model.vendor_name && <span>{model.vendor_name}</span>}
-            {model.vendor_name && (
-              <span className='text-muted-foreground/30'>·</span>
-            )}
-            <span className='text-muted-foreground/60'>
-              {isTokenBased ? t('Token-based') : t('Per Request')}
-            </span>
-            {model.supported_endpoint_types &&
-              model.supported_endpoint_types.length > 0 && (
-                <>
-                  <span className='text-muted-foreground/30'>·</span>
-                  <span className='text-muted-foreground/50'>
-                    {model.supported_endpoint_types.slice(0, 2).join(', ')}
-                    {model.supported_endpoint_types.length > 2 &&
-                      ` +${model.supported_endpoint_types.length - 2}`}
-                  </span>
-                </>
-              )}
-            {isDynamicPricing && (
-              <>
-                <span className='text-muted-foreground/30'>·</span>
-                <span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
-                  {t('Dynamic Pricing')}
-                </span>
-                {dynamicHints && dynamicHints.tierCount > 1 && (
-                  <span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
-                    {t('{{count}} tiers', { count: dynamicHints.tierCount })}
-                  </span>
-                )}
-                {dynamicHints?.hasTimeCondition && (
-                  <span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
-                    {t('Time-based')}
-                  </span>
-                )}
-                {dynamicHints?.hasRequestCondition && (
-                  <span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
-                    {t('Request-based')}
-                  </span>
-                )}
-              </>
-            )}
-          </div>
-
-          {model.description && (
-            <p className='text-muted-foreground/60 mt-1 line-clamp-1 text-xs leading-relaxed'>
-              {model.description}
-            </p>
-          )}
-
-          {tags.length > 0 && (
-            <div className='mt-1.5 flex flex-wrap gap-1'>
-              {tags.slice(0, 4).map((tag) => (
-                <span
-                  key={tag}
-                  className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'
-                >
-                  {tag}
-                </span>
-              ))}
-              {tags.length > 4 && (
-                <span className='text-muted-foreground/40 self-center text-[10px]'>
-                  +{tags.length - 4}
-                </span>
-              )}
-            </div>
-          )}
-        </div>
-
-        <div className='shrink-0 text-right'>
-          {isTokenBased ? (
-            <div className='grid gap-0.5'>
-              <PriceLabel
-                label={t('Input')}
-                value={formatPrice(
-                  model,
-                  'input',
-                  tokenUnit,
-                  showRechargePrice,
-                  priceRate,
-                  usdExchangeRate
-                )}
-              />
-              {hasCachedPrice && (
-                <PriceLabel
-                  label={t('Cached')}
-                  value={formatPrice(
-                    model,
-                    'cache',
-                    tokenUnit,
-                    showRechargePrice,
-                    priceRate,
-                    usdExchangeRate
-                  )}
-                  muted
-                />
-              )}
-              <PriceLabel
-                label={t('Output')}
-                value={formatPrice(
-                  model,
-                  'output',
-                  tokenUnit,
-                  showRechargePrice,
-                  priceRate,
-                  usdExchangeRate
-                )}
-              />
-              <span className='text-muted-foreground/40 text-[10px]'>
-                / {tokenUnitLabel} tokens
-              </span>
-            </div>
-          ) : (
-            <div>
-              <span className='text-foreground text-sm font-semibold tabular-nums'>
-                {formatRequestPrice(
-                  model,
-                  showRechargePrice,
-                  priceRate,
-                  usdExchangeRate
-                )}
-              </span>
-              <div className='text-muted-foreground/40 text-[10px]'>
-                / {t('request')}
-              </div>
-            </div>
-          )}
-        </div>
-
-        <ChevronRight className='text-muted-foreground/20 group-hover:text-muted-foreground/50 mt-1.5 hidden size-4 shrink-0 transition-colors sm:block' />
-      </div>
-    </button>
-  )
-})

+ 97 - 0
web/default/src/features/pricing/components/pricing-columns.tsx

@@ -12,6 +12,10 @@ import { GroupBadge } from '@/components/group-badge'
 import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
 import { parseTags } from '../lib/filters'
 import { isTokenBasedModel } from '../lib/model-helpers'
+import {
+  getDynamicDisplayGroupRatio,
+  getDynamicPricingSummary,
+} from '../lib/dynamic-price'
 import {
   formatPrice,
   formatRequestPrice,
@@ -137,6 +141,63 @@ export function usePricingColumns(
       ),
       cell: ({ row }) => {
         const model = row.original
+        const dynamicSummary = getDynamicPricingSummary(model, {
+          tokenUnit,
+          showRechargePrice,
+          priceRate,
+          usdExchangeRate,
+          groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
+        })
+
+        if (dynamicSummary) {
+          if (dynamicSummary.isSpecialExpression) {
+            return (
+              <div className='min-w-[200px] max-w-[320px]'>
+                <div className='text-amber-700 text-xs font-medium dark:text-amber-300'>
+                  {t('Special billing expression')}
+                </div>
+                <div className='text-muted-foreground text-[11px]'>
+                  {t('Unable to parse structured pricing')}
+                </div>
+                <code className='text-muted-foreground/70 mt-1 line-clamp-2 block break-all font-mono text-[10px] leading-relaxed'>
+                  {dynamicSummary.rawExpression}
+                </code>
+              </div>
+            )
+          }
+
+          const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
+          if (primaryEntries.length === 0) {
+            return (
+              <span className='text-muted-foreground text-xs'>
+                {t('Dynamic Pricing')}
+              </span>
+            )
+          }
+
+          return (
+            <div className='min-w-[180px]'>
+              <span className='font-mono text-sm tabular-nums'>
+                {primaryEntries.map((entry, index) => (
+                  <span key={entry.key}>
+                    {index > 0 && (
+                      <span className='text-muted-foreground/40 mx-1'>/</span>
+                    )}
+                    {stripTrailingZeros(entry.formatted)}
+                  </span>
+                ))}
+              </span>
+              <div className='text-muted-foreground/50 text-[10px]'>
+                / {tokenUnitLabel} tokens
+                {dynamicSummary.tierCount > 1 &&
+                  ` · ${t('{{count}} tiers', {
+                    count: dynamicSummary.tierCount,
+                  })}`}
+              </div>
+            </div>
+          )
+        }
+
         const isTokenBased = isTokenBasedModel(model)
 
         if (isTokenBased) {
@@ -204,6 +265,42 @@ export function usePricingColumns(
       header: t('Cached'),
       cell: ({ row }) => {
         const model = row.original
+        const dynamicSummary = getDynamicPricingSummary(model, {
+          tokenUnit,
+          showRechargePrice,
+          priceRate,
+          usdExchangeRate,
+          groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
+        })
+
+        if (dynamicSummary) {
+          if (dynamicSummary.isSpecialExpression) {
+            return (
+              <span className='text-muted-foreground/50 text-xs'>
+                {t('Special billing expression')}
+              </span>
+            )
+          }
+
+          const cacheEntry = dynamicSummary.entries.find(
+            (entry) => entry.field === 'cacheReadPrice'
+          )
+          if (!cacheEntry) {
+            return <span className='text-muted-foreground/30 text-xs'>—</span>
+          }
+
+          return (
+            <div className='min-w-[80px]'>
+              <span className='font-mono text-sm tabular-nums'>
+                {stripTrailingZeros(cacheEntry.formatted)}
+              </span>
+              <div className='text-muted-foreground/50 text-[10px]'>
+                / {tokenUnitLabel}
+              </div>
+            </div>
+          )
+        }
+
         const isTokenBased = isTokenBasedModel(model)
 
         if (!isTokenBased || model.cache_ratio == null) {

+ 289 - 0
web/default/src/features/pricing/components/pricing-sidebar.tsx

@@ -0,0 +1,289 @@
+import type { ReactNode } from 'react'
+import { ChevronDown, RotateCcw } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { getLobeIcon } from '@/lib/lobe-icon'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+import {
+  ENDPOINT_TYPES,
+  FILTER_ALL,
+  QUOTA_TYPES,
+  getEndpointTypeLabels,
+  getQuotaTypeLabels,
+} from '../constants'
+import { parseTags } from '../lib/filters'
+import type { PricingModel, PricingVendor } from '../types'
+
+type FilterOption = {
+  value: string
+  label: string
+  count?: number
+  suffix?: string
+  icon?: ReactNode
+}
+
+type FilterSectionProps = {
+  title: string
+  value: string
+  options: FilterOption[]
+  onChange: (value: string) => void
+}
+
+export interface PricingSidebarProps {
+  quotaTypeFilter: string
+  endpointTypeFilter: string
+  vendorFilter: string
+  groupFilter: string
+  tagFilter: string
+  onQuotaTypeChange: (value: string) => void
+  onEndpointTypeChange: (value: string) => void
+  onVendorChange: (value: string) => void
+  onGroupChange: (value: string) => void
+  onTagChange: (value: string) => void
+  vendors: PricingVendor[]
+  groups: string[]
+  groupRatios?: Record<string, number>
+  tags: string[]
+  models: PricingModel[]
+  hasActiveFilters: boolean
+  onClearFilters: () => void
+  className?: string
+}
+
+function countBy(
+  models: PricingModel[],
+  predicate: (model: PricingModel) => boolean
+): number {
+  return models.reduce((count, model) => count + (predicate(model) ? 1 : 0), 0)
+}
+
+function formatGroupRatio(ratio: number | undefined): string | undefined {
+  if (ratio == null) return undefined
+  const formatted = Number.isInteger(ratio)
+    ? ratio.toString()
+    : ratio.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
+  return `x${formatted}`
+}
+
+function FilterChip(props: {
+  option: FilterOption
+  active: boolean
+  onClick: () => void
+}) {
+  return (
+    <button
+      type='button'
+      onClick={props.onClick}
+      className={cn(
+        'group inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-all',
+        props.active
+          ? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm'
+          : 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
+      )}
+      title={props.option.label}
+    >
+      {props.option.icon && <span className='shrink-0'>{props.option.icon}</span>}
+      <span className='truncate'>{props.option.label}</span>
+      {(props.option.suffix || props.option.count != null) && (
+        <span
+          className={cn(
+            'rounded-full px-1.5 py-0.5 text-[10px]',
+            props.active
+              ? 'bg-background text-foreground'
+              : 'bg-muted text-muted-foreground'
+          )}
+        >
+          {props.option.suffix ?? props.option.count}
+        </span>
+      )}
+    </button>
+  )
+}
+
+function FilterSection(props: FilterSectionProps) {
+  return (
+    <Collapsible defaultOpen className='border-border/70 border-b pb-3 last:border-b-0'>
+      <CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'>
+        <span className='text-foreground text-sm font-semibold'>
+          {props.title}
+        </span>
+        <ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-180' />
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <div className='flex flex-wrap gap-1.5'>
+          {props.options.map((option) => (
+            <FilterChip
+              key={option.value}
+              option={option}
+              active={props.value === option.value}
+              onClick={() => props.onChange(option.value)}
+            />
+          ))}
+        </div>
+      </CollapsibleContent>
+    </Collapsible>
+  )
+}
+
+export function PricingSidebar(props: PricingSidebarProps) {
+  const { t } = useTranslation()
+  const quotaTypeLabels = getQuotaTypeLabels(t)
+  const endpointTypeLabels = getEndpointTypeLabels(t)
+
+  const vendorOptions: FilterOption[] = [
+    {
+      value: FILTER_ALL,
+      label: t('All Vendors'),
+      count: props.models.length,
+    },
+    ...props.vendors
+      .map((vendor) => ({
+        value: vendor.name,
+        label: vendor.name,
+        count: countBy(
+          props.models,
+          (model) => model.vendor_name === vendor.name
+        ),
+        icon: vendor.icon ? getLobeIcon(vendor.icon, 14) : undefined,
+      }))
+      .filter((vendor) => vendor.count > 0),
+  ]
+
+  const groupOptions: FilterOption[] = [
+    {
+      value: FILTER_ALL,
+      label: t('All Groups'),
+    },
+    ...props.groups.map((group) => ({
+      value: group,
+      label: group,
+      suffix: formatGroupRatio(props.groupRatios?.[group]),
+    })),
+  ]
+
+  const quotaOptions: FilterOption[] = [
+    {
+      value: QUOTA_TYPES.ALL,
+      label: quotaTypeLabels[QUOTA_TYPES.ALL],
+      count: props.models.length,
+    },
+    {
+      value: QUOTA_TYPES.TOKEN,
+      label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
+      count: countBy(props.models, (model) => model.quota_type === 0),
+    },
+    {
+      value: QUOTA_TYPES.REQUEST,
+      label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
+      count: countBy(props.models, (model) => model.quota_type === 1),
+    },
+  ]
+
+  const tagOptions: FilterOption[] = [
+    {
+      value: FILTER_ALL,
+      label: t('All Tags'),
+      count: props.models.length,
+    },
+    ...props.tags.map((tag) => ({
+      value: tag,
+      label: tag,
+      count: countBy(props.models, (model) =>
+        parseTags(model.tags)
+          .map((item) => item.toLowerCase())
+          .includes(tag.toLowerCase())
+      ),
+    })),
+  ]
+
+  const endpointOptions: FilterOption[] = [
+    {
+      value: ENDPOINT_TYPES.ALL,
+      label: endpointTypeLabels[ENDPOINT_TYPES.ALL],
+      count: props.models.length,
+    },
+    ...Object.entries(endpointTypeLabels)
+      .filter(([value]) => value !== ENDPOINT_TYPES.ALL)
+      .map(([value, label]) => ({
+        value,
+        label,
+        count: countBy(props.models, (model) =>
+          model.supported_endpoint_types?.includes(value) ?? false
+        ),
+      })),
+  ]
+
+  return (
+    <aside
+      className={cn(
+        'rounded-xl border p-3',
+        props.className
+      )}
+    >
+      <div className='mb-2.5 flex items-center justify-between gap-2'>
+        <div>
+          <h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2>
+          <p className='text-muted-foreground mt-1 text-xs'>
+            {t('Refine models by provider, group, type, and tags.')}
+          </p>
+        </div>
+        <Button
+          type='button'
+          variant='ghost'
+          size='sm'
+          onClick={props.onClearFilters}
+          disabled={!props.hasActiveFilters}
+          className='h-7 gap-1.5 px-2 text-xs'
+        >
+          <RotateCcw className='size-3.5' />
+          {t('Reset')}
+        </Button>
+      </div>
+
+      {props.hasActiveFilters && (
+        <Badge variant='secondary' className='mb-3'>
+          {t('Filters active')}
+        </Badge>
+      )}
+
+      <div className='space-y-1'>
+        <FilterSection
+          title={t('Groups')}
+          value={props.groupFilter}
+          options={groupOptions}
+          onChange={props.onGroupChange}
+        />
+        <FilterSection
+          title={t('All Vendors')}
+          value={props.vendorFilter}
+          options={vendorOptions}
+          onChange={props.onVendorChange}
+        />
+        <FilterSection
+          title={t('Model Tags')}
+          value={props.tagFilter}
+          options={tagOptions}
+          onChange={props.onTagChange}
+        />
+        <FilterSection
+          title={t('Pricing Type')}
+          value={props.quotaTypeFilter}
+          options={quotaOptions}
+          onChange={props.onQuotaTypeChange}
+        />
+        <FilterSection
+          title={t('Endpoint Type')}
+          value={props.endpointTypeFilter}
+          options={endpointOptions}
+          onChange={props.onEndpointTypeChange}
+        />
+      </div>
+    </aside>
+  )
+}

+ 4 - 8
web/default/src/features/pricing/components/pricing-table.tsx

@@ -1,5 +1,4 @@
 import { useState, useCallback } from 'react'
-import { useNavigate } from '@tanstack/react-router'
 import {
   flexRender,
   getCoreRowModel,
@@ -29,11 +28,11 @@ export interface PricingTableProps {
   usdExchangeRate?: number
   tokenUnit?: TokenUnit
   showRechargePrice?: boolean
+  onModelClick?: (modelName: string) => void
 }
 
 export function PricingTable(props: PricingTableProps) {
   const { t } = useTranslation()
-  const navigate = useNavigate({ from: '/pricing/' })
   const {
     models,
     isLoading = false,
@@ -41,6 +40,7 @@ export function PricingTable(props: PricingTableProps) {
     usdExchangeRate = 1,
     tokenUnit = DEFAULT_TOKEN_UNIT,
     showRechargePrice = false,
+    onModelClick,
   } = props
 
   const [pagination, setPagination] = useState<PaginationState>({
@@ -68,13 +68,9 @@ export function PricingTable(props: PricingTableProps) {
 
   const handleRowClick = useCallback(
     (model: PricingModel) => {
-      navigate({
-        to: '/pricing/$modelId',
-        params: { modelId: model.model_name },
-        search: (prev) => prev,
-      })
+      onModelClick?.(model.model_name)
     },
-    [navigate]
+    [onModelClick]
   )
 
   return (

+ 296 - 0
web/default/src/features/pricing/components/pricing-toolbar.tsx

@@ -0,0 +1,296 @@
+import { useCallback, useState } from 'react'
+import {
+  ArrowUpDown,
+  Check,
+  Filter,
+  Grid2X2,
+  Table2,
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+  Sheet,
+  SheetContent,
+  SheetDescription,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+import {
+  VIEW_MODES,
+  getSortLabels,
+  type SortOption,
+  type ViewMode,
+} from '../constants'
+import type { PricingModel, PricingVendor, TokenUnit } from '../types'
+import { PricingSidebar } from './pricing-sidebar'
+
+type SegmentOption = {
+  value: string
+  label?: string
+  icon?: React.ComponentType<{ className?: string }>
+  tooltip?: string
+}
+
+export interface PricingToolbarProps {
+  filteredCount: number
+  totalCount?: number
+  sortBy: string
+  onSortChange: (value: string) => void
+  tokenUnit: TokenUnit
+  onTokenUnitChange: (value: TokenUnit) => void
+  showRechargePrice: boolean
+  onRechargePriceChange: (value: boolean) => void
+  viewMode: ViewMode
+  onViewModeChange: (value: ViewMode) => void
+  quotaTypeFilter: string
+  endpointTypeFilter: string
+  vendorFilter: string
+  groupFilter: string
+  tagFilter: string
+  onQuotaTypeChange: (value: string) => void
+  onEndpointTypeChange: (value: string) => void
+  onVendorChange: (value: string) => void
+  onGroupChange: (value: string) => void
+  onTagChange: (value: string) => void
+  vendors: PricingVendor[]
+  groups: string[]
+  groupRatios?: Record<string, number>
+  tags: string[]
+  models: PricingModel[]
+  hasActiveFilters: boolean
+  activeFilterCount: number
+  onClearFilters: () => void
+}
+
+function SegmentedControl(props: {
+  options: SegmentOption[]
+  value: string
+  onChange: (value: string) => void
+  ariaLabel: string
+}) {
+  return (
+    <div
+      role='group'
+      aria-label={props.ariaLabel}
+      className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
+    >
+      {props.options.map((option) => {
+        const Icon = option.icon
+        const isActive = option.value === props.value
+        const button = (
+          <button
+            key={option.value}
+            type='button'
+            onClick={() => props.onChange(option.value)}
+            aria-pressed={isActive}
+            className={cn(
+              'inline-flex h-full items-center justify-center rounded-[5px] text-xs font-medium transition-all',
+              Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
+              isActive
+                ? 'bg-foreground text-background shadow-sm'
+                : 'text-muted-foreground hover:text-foreground'
+            )}
+          >
+            {Icon && <Icon className='size-3.5' />}
+            {option.label}
+          </button>
+        )
+
+        if (!option.tooltip) {
+          return button
+        }
+
+        return (
+          <Tooltip key={option.value}>
+            <TooltipTrigger asChild>{button}</TooltipTrigger>
+            <TooltipContent side='bottom' className='text-xs'>
+              {option.tooltip}
+            </TooltipContent>
+          </Tooltip>
+        )
+      })}
+    </div>
+  )
+}
+
+export function PricingToolbar(props: PricingToolbarProps) {
+  const { t } = useTranslation()
+  const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
+  const sortLabels = getSortLabels(t)
+
+  const handleTokenUnitChange = useCallback(
+    (value: string) => props.onTokenUnitChange(value as TokenUnit),
+    [props]
+  )
+
+  const handleViewModeChange = useCallback(
+    (value: string) => props.onViewModeChange(value as ViewMode),
+    [props]
+  )
+
+  const handleRechargePriceChange = useCallback(
+    (value: string) => props.onRechargePriceChange(value === 'recharge'),
+    [props]
+  )
+
+  return (
+    <div className='rounded-xl border p-3'>
+      <div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
+        <div className='flex items-center gap-2'>
+          <Button
+            type='button'
+            variant='outline'
+            size='sm'
+            onClick={() => setMobileFiltersOpen(true)}
+            className='gap-1.5 xl:hidden'
+          >
+            <Filter className='size-4' />
+            {t('Filter')}
+            {props.activeFilterCount > 0 && (
+              <Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
+                {props.activeFilterCount}
+              </Badge>
+            )}
+          </Button>
+
+          <div className='text-muted-foreground flex items-baseline gap-1 text-sm'>
+            <span className='text-foreground font-semibold tabular-nums'>
+              {props.filteredCount.toLocaleString()}
+            </span>
+            <span>
+              {props.filteredCount === 1 ? t('model') : t('models')}
+            </span>
+            {props.hasActiveFilters && props.totalCount && (
+              <span className='text-muted-foreground/60 text-xs'>
+                / {props.totalCount.toLocaleString()}
+              </span>
+            )}
+          </div>
+        </div>
+
+        <div className='flex flex-wrap items-center gap-2'>
+          <div className='hidden items-center gap-2 sm:flex'>
+            <SegmentedControl
+              options={[
+                { value: 'standard', label: t('Standard') },
+                { value: 'recharge', label: t('Recharge') },
+              ]}
+              value={props.showRechargePrice ? 'recharge' : 'standard'}
+              onChange={handleRechargePriceChange}
+              ariaLabel={t('Price display mode')}
+            />
+            <SegmentedControl
+              options={[
+                { value: 'M', label: '/1M' },
+                { value: 'K', label: '/1K' },
+              ]}
+              value={props.tokenUnit}
+              onChange={handleTokenUnitChange}
+              ariaLabel={t('Token unit')}
+            />
+          </div>
+
+          <DropdownMenu>
+            <DropdownMenuTrigger asChild>
+              <Button
+                type='button'
+                variant='outline'
+                size='sm'
+                className='h-8 gap-1.5 px-3 text-xs'
+              >
+                <ArrowUpDown className='size-3.5' />
+                <span>
+                  {sortLabels[props.sortBy as SortOption] || t('Sort')}
+                </span>
+              </Button>
+            </DropdownMenuTrigger>
+            <DropdownMenuContent align='end' className='w-44'>
+              {Object.entries(sortLabels).map(([value, label]) => (
+                <DropdownMenuItem
+                  key={value}
+                  onClick={() => props.onSortChange(value)}
+                  className='gap-2'
+                >
+                  <Check
+                    className={cn(
+                      'size-4 shrink-0',
+                      props.sortBy === value ? 'opacity-100' : 'opacity-0'
+                    )}
+                  />
+                  {label}
+                </DropdownMenuItem>
+              ))}
+            </DropdownMenuContent>
+          </DropdownMenu>
+
+          <SegmentedControl
+            options={[
+              {
+                value: VIEW_MODES.CARD,
+                icon: Grid2X2,
+                tooltip: t('Card view'),
+              },
+              {
+                value: VIEW_MODES.TABLE,
+                icon: Table2,
+                tooltip: t('Table view'),
+              },
+            ]}
+            value={props.viewMode}
+            onChange={handleViewModeChange}
+            ariaLabel={t('View mode')}
+          />
+        </div>
+      </div>
+
+      <Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
+        <SheetContent
+          side='right'
+          className='flex w-full flex-col overflow-hidden p-0 sm:max-w-md'
+        >
+          <SheetHeader className='border-b px-6 py-4'>
+            <SheetTitle>{t('Filter')}</SheetTitle>
+            <SheetDescription>
+              {t('Filter models by provider, group, type, endpoint, and tags.')}
+            </SheetDescription>
+          </SheetHeader>
+          <div className='flex-1 overflow-y-auto p-4'>
+            <PricingSidebar
+              quotaTypeFilter={props.quotaTypeFilter}
+              endpointTypeFilter={props.endpointTypeFilter}
+              vendorFilter={props.vendorFilter}
+              groupFilter={props.groupFilter}
+              tagFilter={props.tagFilter}
+              onQuotaTypeChange={props.onQuotaTypeChange}
+              onEndpointTypeChange={props.onEndpointTypeChange}
+              onVendorChange={props.onVendorChange}
+              onGroupChange={props.onGroupChange}
+              onTagChange={props.onTagChange}
+              vendors={props.vendors}
+              groups={props.groups}
+              groupRatios={props.groupRatios}
+              tags={props.tags}
+              models={props.models}
+              hasActiveFilters={props.hasActiveFilters}
+              onClearFilters={props.onClearFilters}
+              className='border-0 bg-transparent p-0 shadow-none'
+            />
+          </div>
+        </SheetContent>
+      </Sheet>
+    </div>
+  )
+}

+ 3 - 3
web/default/src/features/pricing/components/search-bar.tsx

@@ -40,9 +40,9 @@ export function SearchBar(props: SearchBarProps) {
         value={props.value}
         onChange={(e) => props.onChange(e.target.value)}
         className={cn(
-          'border-border/60 bg-muted/30 placeholder:text-muted-foreground/50',
-          'hover:border-border hover:bg-muted/50',
-          'focus:bg-background focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
+          'border-border/60 bg-background placeholder:text-muted-foreground/50',
+          'hover:border-border',
+          'focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
           'h-10 w-full rounded-lg border pr-16 pl-10 text-sm transition-all outline-none'
         )}
         aria-label={t('Search models')}

+ 0 - 70
web/default/src/features/pricing/components/virtual-model-list.tsx

@@ -1,70 +0,0 @@
-import { useWindowVirtualizer } from '@tanstack/react-virtual'
-import { DEFAULT_TOKEN_UNIT } from '../constants'
-import type { PricingModel, TokenUnit } from '../types'
-import { ModelRow } from './model-row'
-
-export interface VirtualModelListProps {
-  models: PricingModel[]
-  onModelClick: (modelName: string) => void
-  estimateSize?: number
-  overscan?: number
-  priceRate?: number
-  usdExchangeRate?: number
-  tokenUnit?: TokenUnit
-  showRechargePrice?: boolean
-}
-
-export function VirtualModelList(props: VirtualModelListProps) {
-  const estimateSize = props.estimateSize ?? 130
-  const overscan = props.overscan ?? 5
-  const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
-
-  const virtualizer = useWindowVirtualizer({
-    count: props.models.length,
-    estimateSize: () => estimateSize,
-    overscan,
-    measureElement:
-      typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
-        ? (element) => element?.getBoundingClientRect().height
-        : undefined,
-  })
-
-  const items = virtualizer.getVirtualItems()
-
-  if (props.models.length === 0) {
-    return null
-  }
-
-  return (
-    <div
-      className='overflow-hidden rounded-lg border'
-      style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
-    >
-      {items.map((virtualItem) => {
-        const model = props.models[virtualItem.index]
-        const key =
-          model.id ??
-          `${model.vendor_name}-${model.model_name}-${virtualItem.index}`
-
-        return (
-          <div
-            key={key}
-            data-index={virtualItem.index}
-            ref={virtualizer.measureElement}
-            className='absolute top-0 left-0 w-full'
-            style={{ transform: `translateY(${virtualItem.start}px)` }}
-          >
-            <ModelRow
-              model={model}
-              priceRate={props.priceRate}
-              usdExchangeRate={props.usdExchangeRate}
-              tokenUnit={tokenUnit}
-              showRechargePrice={props.showRechargePrice}
-              onClick={() => props.onModelClick(model.model_name || '')}
-            />
-          </div>
-        )
-      })}
-    </div>
-  )
-}

+ 1 - 1
web/default/src/features/pricing/constants.ts

@@ -116,7 +116,7 @@ export const DEFAULT_TOKEN_UNIT: TokenUnit = 'M'
 
 /** View mode options */
 export const VIEW_MODES = {
-  LIST: 'list',
+  CARD: 'card',
   TABLE: 'table',
 } as const
 

+ 77 - 51
web/default/src/features/pricing/hooks/use-filters.ts

@@ -1,5 +1,5 @@
-import { useMemo, useCallback } from 'react'
-import { useSearch, useNavigate } from '@tanstack/react-router'
+import { useMemo, useCallback, useState } from 'react'
+import { useSearch } from '@tanstack/react-router'
 import {
   FILTER_ALL,
   SORT_OPTIONS,
@@ -12,88 +12,114 @@ import {
 import { filterAndSortModels, extractAllTags } from '../lib/filters'
 import type { PricingModel, TokenUnit } from '../types'
 
+type FilterState = {
+  search?: string
+  sort?: string
+  vendor?: string
+  group?: string
+  quotaType?: string
+  endpointType?: string
+  tag?: string
+  tokenUnit?: TokenUnit
+  view?: ViewMode
+  rechargePrice?: boolean
+}
+
+function normalizeViewMode(value: unknown): ViewMode {
+  if (value === VIEW_MODES.TABLE) {
+    return VIEW_MODES.TABLE
+  }
+  return VIEW_MODES.CARD
+}
+
 export function useFilters(models: PricingModel[]) {
   const search = useSearch({ from: '/pricing/' })
-  const navigate = useNavigate({ from: '/pricing/' })
+  const [filterState, setFilterState] = useState<FilterState>(() => ({
+    search: search.search,
+    sort: search.sort,
+    vendor: search.vendor,
+    group: search.group,
+    quotaType: search.quotaType,
+    endpointType: search.endpointType,
+    tag: search.tag,
+    tokenUnit: search.tokenUnit,
+    view: search.view,
+    rechargePrice: search.rechargePrice,
+  }))
 
-  const searchInput = search.search || ''
-  const sortBy = search.sort || SORT_OPTIONS.NAME
-  const vendorFilter = search.vendor || FILTER_ALL
-  const groupFilter = search.group || FILTER_ALL
-  const quotaTypeFilter = search.quotaType || QUOTA_TYPES.ALL
-  const endpointTypeFilter = search.endpointType || ENDPOINT_TYPES.ALL
-  const tagFilter = search.tag || FILTER_ALL
+  const searchInput = filterState.search || ''
+  const sortBy = filterState.sort || SORT_OPTIONS.NAME
+  const vendorFilter = filterState.vendor || FILTER_ALL
+  const groupFilter = filterState.group || FILTER_ALL
+  const quotaTypeFilter = filterState.quotaType || QUOTA_TYPES.ALL
+  const endpointTypeFilter = filterState.endpointType || ENDPOINT_TYPES.ALL
+  const tagFilter = filterState.tag || FILTER_ALL
   const tokenUnit: TokenUnit =
-    search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
-  const viewMode: ViewMode =
-    search.view === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST
-  const showRechargePrice = search.rechargePrice === true
+    filterState.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
+  const viewMode = normalizeViewMode(filterState.view)
+  const showRechargePrice = filterState.rechargePrice === true
 
-  const updateSearch = useCallback(
+  const updateFilters = useCallback(
     (updates: Record<string, unknown>) => {
-      navigate({
-        to: '/pricing' as const,
-        search: (prev) => {
-          const next: Record<string, unknown> = { ...prev, ...updates }
-          for (const key of Object.keys(next)) {
-            if (next[key] === undefined || next[key] === null) {
-              delete next[key]
-            }
+      setFilterState((prev) => {
+        const next: Record<string, unknown> = { ...prev, ...updates }
+        for (const key of Object.keys(next)) {
+          if (next[key] === undefined || next[key] === null) {
+            delete next[key]
           }
-          return next
-        },
-        replace: true,
+        }
+        return next as FilterState
       })
     },
-    [navigate]
+    []
   )
 
   const setSearchInput = useCallback(
-    (v: string) => updateSearch({ search: v || undefined }),
-    [updateSearch]
+    (v: string) => updateFilters({ search: v || undefined }),
+    [updateFilters]
   )
   const setSortBy = useCallback(
     (v: string) =>
-      updateSearch({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
-    [updateSearch]
+      updateFilters({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
+    [updateFilters]
   )
   const setVendorFilter = useCallback(
-    (v: string) => updateSearch({ vendor: v === FILTER_ALL ? undefined : v }),
-    [updateSearch]
+    (v: string) => updateFilters({ vendor: v === FILTER_ALL ? undefined : v }),
+    [updateFilters]
   )
   const setGroupFilter = useCallback(
-    (v: string) => updateSearch({ group: v === FILTER_ALL ? undefined : v }),
-    [updateSearch]
+    (v: string) => updateFilters({ group: v === FILTER_ALL ? undefined : v }),
+    [updateFilters]
   )
   const setQuotaTypeFilter = useCallback(
     (v: string) =>
-      updateSearch({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
-    [updateSearch]
+      updateFilters({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
+    [updateFilters]
   )
   const setEndpointTypeFilter = useCallback(
     (v: string) =>
-      updateSearch({
+      updateFilters({
         endpointType: v === ENDPOINT_TYPES.ALL ? undefined : v,
       }),
-    [updateSearch]
+    [updateFilters]
   )
   const setTagFilter = useCallback(
-    (v: string) => updateSearch({ tag: v === FILTER_ALL ? undefined : v }),
-    [updateSearch]
+    (v: string) => updateFilters({ tag: v === FILTER_ALL ? undefined : v }),
+    [updateFilters]
   )
   const setTokenUnit = useCallback(
     (v: TokenUnit) =>
-      updateSearch({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
-    [updateSearch]
+      updateFilters({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
+    [updateFilters]
   )
   const setViewMode = useCallback(
     (v: ViewMode) =>
-      updateSearch({ view: v === VIEW_MODES.LIST ? undefined : v }),
-    [updateSearch]
+      updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }),
+    [updateFilters]
   )
   const setShowRechargePrice = useCallback(
-    (v: boolean) => updateSearch({ rechargePrice: v || undefined }),
-    [updateSearch]
+    (v: boolean) => updateFilters({ rechargePrice: v || undefined }),
+    [updateFilters]
   )
 
   const availableTags = useMemo(() => {
@@ -145,18 +171,18 @@ export function useFilters(models: PricingModel[]) {
   )
 
   const clearFilters = useCallback(() => {
-    updateSearch({
+    updateFilters({
       vendor: undefined,
       group: undefined,
       quotaType: undefined,
       endpointType: undefined,
       tag: undefined,
     })
-  }, [updateSearch])
+  }, [updateFilters])
 
   const clearSearch = useCallback(() => {
-    updateSearch({ search: undefined })
-  }, [updateSearch])
+    updateFilters({ search: undefined })
+  }, [updateFilters])
 
   return {
     searchInput,

+ 177 - 85
web/default/src/features/pricing/index.tsx

@@ -1,6 +1,4 @@
-import { useCallback, useMemo } from 'react'
-import { useNavigate } from '@tanstack/react-router'
-import { useMediaQuery } from '@/hooks'
+import { useCallback, useMemo, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { PublicLayout } from '@/components/layout'
 import { PageTransition } from '@/components/page-transition'
@@ -8,9 +6,11 @@ import {
   LoadingSkeleton,
   EmptyState,
   SearchBar,
-  FilterBar,
-  VirtualModelList,
   PricingTable,
+  PricingSidebar,
+  PricingToolbar,
+  ModelCardGrid,
+  ModelDetailsDrawer,
 } from './components'
 import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
 import { useFilters } from './hooks/use-filters'
@@ -18,13 +18,15 @@ import { usePricingData } from './hooks/use-pricing-data'
 
 export function Pricing() {
   const { t } = useTranslation()
-  const navigate = useNavigate({ from: '/pricing/' })
-  const isMobile = useMediaQuery('(max-width: 640px)')
+  const [selectedModelName, setSelectedModelName] = useState<string | null>(null)
 
   const {
     models,
     vendors,
+    groupRatio,
     usableGroup,
+    endpointMap,
+    autoGroups,
     isLoading,
     priceRate,
     usdExchangeRate,
@@ -61,13 +63,18 @@ export function Pricing() {
 
   const handleModelClick = useCallback(
     (modelName: string) => {
-      navigate({
-        to: '/pricing/$modelId',
-        params: { modelId: modelName },
-        search: (prev) => prev,
-      })
+      setSelectedModelName(modelName)
     },
-    [navigate]
+    []
+  )
+
+  const selectedModel = useMemo(
+    () =>
+      selectedModelName
+        ? (models || []).find((model) => model.model_name === selectedModelName) ||
+          null
+        : null,
+    [models, selectedModelName]
   )
 
   const availableGroups = useMemo(
@@ -83,10 +90,46 @@ export function Pricing() {
     clearSearch()
   }, [clearFilters, clearSearch])
 
+  const renderPricingContent = () => {
+    if (filteredModels.length === 0) {
+      return (
+        <EmptyState
+          searchQuery={searchInput}
+          hasActiveFilters={hasActiveFilters}
+          onClearFilters={handleClearAll}
+        />
+      )
+    }
+
+    if (viewMode === VIEW_MODES.CARD) {
+      return (
+        <ModelCardGrid
+          models={filteredModels}
+          onModelClick={handleModelClick}
+          priceRate={priceRate}
+          usdExchangeRate={usdExchangeRate}
+          tokenUnit={tokenUnit}
+          showRechargePrice={showRechargePrice}
+        />
+      )
+    }
+
+    return (
+      <PricingTable
+        models={filteredModels}
+        priceRate={priceRate}
+        usdExchangeRate={usdExchangeRate}
+        tokenUnit={tokenUnit}
+        showRechargePrice={showRechargePrice}
+        onModelClick={handleModelClick}
+      />
+    )
+  }
+
   if (isLoading) {
     return (
-      <PublicLayout>
-        <div className='mx-auto max-w-6xl px-4 sm:px-6'>
+      <PublicLayout showMainContainer={false}>
+        <div className='mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
           <LoadingSkeleton viewMode={viewMode} />
         </div>
       </PublicLayout>
@@ -94,81 +137,130 @@ export function Pricing() {
   }
 
   return (
-    <PublicLayout>
-      <PageTransition className='mx-auto max-w-6xl px-4 sm:px-6'>
-        <header className='mb-6 sm:mb-8'>
-          <h1 className='text-2xl font-bold tracking-tight sm:text-3xl'>
-            {t('Model Pricing')}
-          </h1>
-          <p className='text-muted-foreground mt-1 text-sm'>
-            {t('Browse and compare')} {models?.length || 0} {t('models')}
-          </p>
-        </header>
-
-        <div className='space-y-4'>
-          <SearchBar
-            value={searchInput}
-            onChange={setSearchInput}
-            onClear={clearSearch}
-          />
-
-          <FilterBar
-            quotaTypeFilter={quotaTypeFilter}
-            endpointTypeFilter={endpointTypeFilter}
-            vendorFilter={vendorFilter}
-            groupFilter={groupFilter}
-            tagFilter={tagFilter}
-            onQuotaTypeChange={setQuotaTypeFilter}
-            onEndpointTypeChange={setEndpointTypeFilter}
-            onVendorChange={setVendorFilter}
-            onGroupChange={setGroupFilter}
-            onTagChange={setTagFilter}
-            vendors={vendors || []}
-            groups={availableGroups}
-            tags={availableTags}
-            sortBy={sortBy}
-            onSortChange={setSortBy}
-            tokenUnit={tokenUnit}
-            onTokenUnitChange={setTokenUnit}
-            showRechargePrice={showRechargePrice}
-            onRechargePriceChange={setShowRechargePrice}
-            viewMode={viewMode}
-            onViewModeChange={setViewMode}
-            hasActiveFilters={hasActiveFilters}
-            activeFilterCount={activeFilterCount}
-            onClearFilters={clearFilters}
-            filteredCount={filteredModels.length}
-            totalCount={models?.length}
-          />
-
-          {filteredModels.length > 0 ? (
-            isMobile || viewMode === VIEW_MODES.LIST ? (
-              <VirtualModelList
-                models={filteredModels}
-                onModelClick={handleModelClick}
-                priceRate={priceRate}
-                usdExchangeRate={usdExchangeRate}
-                tokenUnit={tokenUnit}
-                showRechargePrice={showRechargePrice}
-              />
-            ) : (
-              <PricingTable
-                models={filteredModels}
-                priceRate={priceRate}
-                usdExchangeRate={usdExchangeRate}
+    <PublicLayout showMainContainer={false}>
+      <div className='relative'>
+        <div
+          aria-hidden
+          className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]'
+          style={{
+            background: [
+              'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
+              'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
+              'radial-gradient(ellipse 40% 35% at 50% 70%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
+            ].join(', '),
+            maskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
+            WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
+          }}
+        />
+        <PageTransition className='relative mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
+          <header className='mx-auto mb-8 max-w-3xl pt-8 text-center sm:mb-10 sm:pt-10'>
+            <p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
+              {t('Models Directory')}
+            </p>
+            <h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
+              {t('Model Square')}
+            </h1>
+            <p className='text-muted-foreground/80 mt-4 text-sm sm:text-base'>
+              {t('This site currently has {{count}} models enabled', {
+                count: models?.length || 0,
+              })}
+            </p>
+            <p className='text-muted-foreground/60 mx-auto mt-2 max-w-2xl text-xs leading-relaxed sm:text-sm'>
+              {t(
+                'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
+              )}
+            </p>
+            <SearchBar
+              value={searchInput}
+              onChange={setSearchInput}
+              onClear={clearSearch}
+              placeholder={t('Search model name, provider, endpoint, or tag...')}
+              className='mx-auto mt-6 max-w-2xl'
+            />
+          </header>
+
+          <div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)] 2xl:grid-cols-[330px_minmax(0,1fr)]'>
+            <PricingSidebar
+              quotaTypeFilter={quotaTypeFilter}
+              endpointTypeFilter={endpointTypeFilter}
+              vendorFilter={vendorFilter}
+              groupFilter={groupFilter}
+              tagFilter={tagFilter}
+              onQuotaTypeChange={setQuotaTypeFilter}
+              onEndpointTypeChange={setEndpointTypeFilter}
+              onVendorChange={setVendorFilter}
+              onGroupChange={setGroupFilter}
+              onTagChange={setTagFilter}
+              vendors={vendors || []}
+              groups={availableGroups}
+              groupRatios={groupRatio}
+              tags={availableTags}
+              models={models || []}
+              hasActiveFilters={hasActiveFilters}
+              onClearFilters={clearFilters}
+              className='sticky top-20 hidden max-h-[calc(100vh-6rem)] overflow-y-auto xl:block'
+            />
+
+            <main className='min-w-0 space-y-4'>
+              <PricingToolbar
+                filteredCount={filteredModels.length}
+                totalCount={models?.length}
+                sortBy={sortBy}
+                onSortChange={setSortBy}
                 tokenUnit={tokenUnit}
+                onTokenUnitChange={setTokenUnit}
                 showRechargePrice={showRechargePrice}
+                onRechargePriceChange={setShowRechargePrice}
+                viewMode={viewMode}
+                onViewModeChange={setViewMode}
+                quotaTypeFilter={quotaTypeFilter}
+                endpointTypeFilter={endpointTypeFilter}
+                vendorFilter={vendorFilter}
+                groupFilter={groupFilter}
+                tagFilter={tagFilter}
+                onQuotaTypeChange={setQuotaTypeFilter}
+                onEndpointTypeChange={setEndpointTypeFilter}
+                onVendorChange={setVendorFilter}
+                onGroupChange={setGroupFilter}
+                onTagChange={setTagFilter}
+                vendors={vendors || []}
+                groups={availableGroups}
+                groupRatios={groupRatio}
+                tags={availableTags}
+                models={models || []}
+                hasActiveFilters={hasActiveFilters}
+                activeFilterCount={activeFilterCount}
+                onClearFilters={clearFilters}
               />
-            )
-          ) : (
-            <EmptyState
-              searchQuery={searchInput}
-              hasActiveFilters={hasActiveFilters}
-              onClearFilters={handleClearAll}
+
+              {renderPricingContent()}
+            </main>
+          </div>
+
+          {selectedModel && (
+            <ModelDetailsDrawer
+              open={Boolean(selectedModel)}
+              onOpenChange={(open) => {
+                if (!open) setSelectedModelName(null)
+              }}
+              model={selectedModel}
+              groupRatio={groupRatio || {}}
+              usableGroup={usableGroup || {}}
+              endpointMap={
+                (endpointMap as Record<
+                  string,
+                  { path?: string; method?: string }
+                >) || {}
+              }
+              autoGroups={autoGroups || []}
+              priceRate={priceRate ?? 1}
+              usdExchangeRate={usdExchangeRate ?? 1}
+              tokenUnit={tokenUnit}
+              showRechargePrice={showRechargePrice}
             />
           )}
-        </div>
-      </PageTransition>
+        </PageTransition>
+      </div>
     </PublicLayout>
   )
 }

+ 169 - 0
web/default/src/features/pricing/lib/dynamic-price.ts

@@ -0,0 +1,169 @@
+import { formatBillingCurrencyFromUSD } from '@/lib/currency'
+import { TOKEN_UNIT_DIVISORS } from '../constants'
+import {
+  BILLING_PRICING_VARS,
+  parseTiersFromExpr,
+  splitBillingExprAndRequestRules,
+  tryParseRequestRuleExpr,
+  type BillingVar,
+  type ParsedTier,
+} from './billing-expr'
+import type { PricingModel, TokenUnit } from '../types'
+
+type DynamicPriceOptions = {
+  tokenUnit: TokenUnit
+  showRechargePrice?: boolean
+  priceRate?: number
+  usdExchangeRate?: number
+  groupRatioMultiplier?: number
+}
+
+export type DynamicPriceEntry = {
+  key: string
+  field: string
+  label: string
+  shortLabel: string
+  value: number
+  formatted: string
+  variable: BillingVar
+}
+
+export type DynamicPricingSummary = {
+  tiers: ParsedTier[]
+  tier: ParsedTier | null
+  tierCount: number
+  hasRequestRules: boolean
+  isSpecialExpression: boolean
+  rawExpression: string
+  entries: DynamicPriceEntry[]
+  primaryEntries: DynamicPriceEntry[]
+  secondaryEntries: DynamicPriceEntry[]
+}
+
+const PRIMARY_DYNAMIC_FIELDS = new Set(['inputPrice', 'outputPrice'])
+
+export function isDynamicPricingModel(model: PricingModel): boolean {
+  return model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr)
+}
+
+export function getDynamicDisplayGroupRatio(model: PricingModel): number {
+  const groups = Array.isArray(model.enable_groups) ? model.enable_groups : []
+  const ratios = model.group_ratio || {}
+  if (groups.length === 0) return 1
+
+  let minRatio = Number.POSITIVE_INFINITY
+  for (const group of groups) {
+    const ratio = ratios[group]
+    if (ratio !== undefined && ratio < minRatio) {
+      minRatio = ratio
+    }
+  }
+
+  return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio
+}
+
+function applyRechargeRate(
+  price: number,
+  showWithRecharge: boolean,
+  priceRate: number,
+  usdExchangeRate: number
+): number {
+  if (!showWithRecharge) return price
+  return (price * priceRate) / usdExchangeRate
+}
+
+export function formatDynamicUnitPrice(
+  valuePerMillionTokens: number,
+  options: DynamicPriceOptions
+): string {
+  const groupRatio = options.groupRatioMultiplier ?? 1
+  const priceRate = options.priceRate ?? 1
+  const usdExchangeRate = options.usdExchangeRate ?? 1
+  const priceUSD =
+    (valuePerMillionTokens * groupRatio) /
+    TOKEN_UNIT_DIVISORS[options.tokenUnit]
+  const displayPrice = applyRechargeRate(
+    priceUSD,
+    options.showRechargePrice ?? false,
+    priceRate,
+    usdExchangeRate
+  )
+
+  return formatBillingCurrencyFromUSD(displayPrice, {
+    digitsLarge: 4,
+    digitsSmall: 6,
+    abbreviate: false,
+  })
+}
+
+export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
+  if (!isDynamicPricingModel(model)) return []
+  const { billingExpr } = splitBillingExprAndRequestRules(model.billing_expr || '')
+  return parseTiersFromExpr(billingExpr)
+}
+
+export function hasDynamicRequestRules(model: PricingModel): boolean {
+  if (!isDynamicPricingModel(model)) return false
+  const { requestRuleExpr } = splitBillingExprAndRequestRules(
+    model.billing_expr || ''
+  )
+  return Boolean(tryParseRequestRuleExpr(requestRuleExpr || '')?.length)
+}
+
+export function getDynamicPriceEntries(
+  tier: ParsedTier | null,
+  options: DynamicPriceOptions
+): DynamicPriceEntry[] {
+  if (!tier) return []
+
+  return BILLING_PRICING_VARS.flatMap((variable) => {
+    if (!variable.field) return []
+    const value = Number(tier[variable.field])
+    if (!Number.isFinite(value) || value <= 0) return []
+
+    return [
+      {
+        key: variable.key,
+        field: variable.field,
+        label: variable.label,
+        shortLabel: variable.shortLabel,
+        value,
+        formatted: formatDynamicUnitPrice(value, options),
+        variable,
+      },
+    ]
+  }).sort((a, b) => {
+    const aPrimary = PRIMARY_DYNAMIC_FIELDS.has(a.field)
+    const bPrimary = PRIMARY_DYNAMIC_FIELDS.has(b.field)
+    if (aPrimary !== bPrimary) return aPrimary ? -1 : 1
+    return 0
+  })
+}
+
+export function getDynamicPricingSummary(
+  model: PricingModel,
+  options: DynamicPriceOptions
+): DynamicPricingSummary | null {
+  if (!isDynamicPricingModel(model)) return null
+
+  const tiers = getDynamicPricingTiers(model)
+  const tier = tiers[0] || null
+  const entries = getDynamicPriceEntries(tier, options)
+  const rawExpression = model.billing_expr || ''
+
+  return {
+    tiers,
+    tier,
+    tierCount: tiers.length,
+    hasRequestRules: hasDynamicRequestRules(model),
+    isSpecialExpression: rawExpression.trim().length > 0 && tiers.length === 0,
+    rawExpression,
+    entries,
+    primaryEntries: entries.filter((entry) =>
+      PRIMARY_DYNAMIC_FIELDS.has(entry.field)
+    ),
+    secondaryEntries: entries.filter(
+      (entry) => !PRIMARY_DYNAMIC_FIELDS.has(entry.field)
+    ),
+  }
+}

+ 1 - 1
web/default/src/hooks/use-top-nav-links.ts

@@ -71,7 +71,7 @@ export function useTopNavLinks(): TopNavLink[] {
   const pricing = modules?.pricing
   if (pricing && typeof pricing === 'object' && pricing.enabled) {
     const disabled = pricing.requireAuth && !isAuthed
-    links.push({ title: t('Pricing'), href: '/pricing', disabled })
+    links.push({ title: t('Model Square'), href: '/pricing', disabled })
   }
 
   // Docs (supports external links)

+ 29 - 2
web/default/src/i18n/locales/en.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "Broadcast a global banner to users. Markdown is supported.",
     "Broadcast short system notices on the dashboard": "Broadcast short system notices on the dashboard",
     "Browse and compare": "Browse and compare",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.",
     "Budget Tokens Ratio": "Budget Tokens Ratio",
@@ -527,6 +528,7 @@
     "Cancelled": "Cancelled",
     "Cancelled at": "Cancelled at",
     "Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.",
+    "Card view": "Card view",
     "Category Name": "Category Name",
     "Category name is required": "Category name is required",
     "Category name must be less than 50 characters": "Category name must be less than 50 characters",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "Choose how to filter domains",
     "Choose how to filter IP addresses": "Choose how to filter IP addresses",
     "Choose the bundle type and define the items inside it.": "Choose the bundle type and define the items inside it.",
-    "Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
     "Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.",
+    "Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.",
     "Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.",
     "Classic (Legacy Frontend)": "Classic (Legacy Frontend)",
     "Claude": "Claude",
@@ -676,6 +678,9 @@
     "Common Logs": "Common Logs",
     "Common ports include 25, 465, and 587": "Common ports include 25, 465, and 587",
     "Common User": "Common User",
+    "Community driven, self-hosted, and extensible": "Community driven, self-hosted, and extensible",
+    "compatible API routes": "compatible API routes",
+    "Compatible API routes for common AI application workflows": "Compatible API routes for common AI application workflows",
     "Complete API documentation with multi-language SDK support": "Complete API documentation with multi-language SDK support",
     "Complete Order": "Complete Order",
     "Complete these steps to finish the initial installation.": "Complete these steps to finish the initial installation.",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "Confirm your identity with Two-factor Authentication before registering a Passkey.",
     "Conflict": "Conflict",
     "Connect": "Connect",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connect through OpenAI, Claude, Gemini, and other compatible API routes",
     "Connected to io.net service normally.": "Connected to io.net service normally.",
     "Connection error": "Connection error",
     "Connection failed": "Connection failed",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "Demo site mode",
     "Demo Site Mode": "Demo Site Mode",
     "Demote": "Demote",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "Deploy your own gateway and start routing requests through your configured upstream services.",
     "Deployment created successfully": "Deployment created successfully",
     "Deployment details": "Deployment details",
     "Deployment ID": "Deployment ID",
@@ -1553,10 +1560,12 @@
     "Filter by username": "Filter by username",
     "Filter by username, name or email...": "Filter by username, name or email...",
     "Filter Dashboard Models": "Filter Dashboard Models",
+    "Filter models by provider, group, type, endpoint, and tags.": "Filter models by provider, group, type, endpoint, and tags.",
     "Filter models by type, endpoint, vendor, group and tags": "Filter models by type, endpoint, vendor, group and tags",
     "Filter models...": "Filter models...",
     "Filter...": "Filter...",
     "Filters": "Filters",
+    "Filters active": "Filters active",
     "Final Consumed": "Final Consumed",
     "Final cost = base × multiplier when conditions match": "Final cost = base × multiplier when conditions match",
     "Final price multiplier (0.95 = 5% discount": "Final price multiplier (0.95 = 5% discount",
@@ -1676,6 +1685,7 @@
     "Group name": "Group name",
     "Group Name": "Group Name",
     "Group name cannot be changed when editing.": "Group name cannot be changed when editing.",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.",
     "group ratio": "group ratio",
     "Group Ratio": "Group Ratio",
     "Group ratios": "Group ratios",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "IP Filter Mode",
     "IP Restriction": "IP Restriction",
     "IP Whitelist (supports CIDR)": "IP Whitelist (supports CIDR)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.",
     "is less than the configured maximum cache size": "is less than the configured maximum cache size",
     "is the default price; ": "is the default price; ",
     "It seems like the page you're looking for": "It seems like the page you're looking for",
@@ -2051,6 +2062,7 @@
     "Model": "Model",
     "Model Access": "Model Access",
     "Model Analytics": "Model Analytics",
+    "model billing support": "model billing support",
     "Model Call Analytics": "Model Call Analytics",
     "Model context usage": "Model context usage",
     "Model deleted": "Model deleted",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "Model ratios reset successfully",
     "Model Regex": "Model Regex",
     "Model Regex (one per line)": "Model Regex (one per line)",
+    "Model Square": "Model Square",
+    "Model Tags": "Model Tags",
     "Model to use for testing": "Model to use for testing",
     "Model to use when testing channel connectivity": "Model to use when testing channel connectivity",
     "Model Version *": "Model Version *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "Models appended successfully",
     "Models are required": "Models are required",
     "Models directory": "Models directory",
+    "Models Directory": "Models Directory",
     "Models fetched successfully": "Models fetched successfully",
     "Models filled to form": "Models filled to form",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "Multi-Key Strategy",
     "Multi-key: Polling rotation": "Multi-key: Polling rotation",
     "Multi-key: Random rotation": "Multi-key: Random rotation",
+    "Multi-protocol Compatible": "Multi-protocol Compatible",
     "Multi-region deployment for stable global access": "Multi-region deployment for stable global access",
     "Multi-user management with flexible permission allocation": "Multi-user management with flexible permission allocation",
     "Multiplier": "Multiplier",
@@ -2216,6 +2232,7 @@
     "No data available": "No data available",
     "No deployments available. Create one to get started.": "No deployments available. Create one to get started.",
     "No Deployments Found": "No Deployments Found",
+    "No description available.": "No description available.",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "No discount tiers configured. Click \"Add discount tier\" to get started.",
     "No duplicate keys found": "No duplicate keys found",
     "No enabled tokens available": "No enabled tokens available",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "Open in New Tab",
     "Open menu": "Open menu",
     "Open release": "Open release",
+    "Open Source": "Open Source",
     "Open the io.net console API Keys page": "Open the io.net console API Keys page",
     "Open theme settings": "Open theme settings",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "Previous",
     "Previous branch": "Previous branch",
     "Previous page": "Previous page",
+    "Base Price": "Base Price",
     "Price": "Price",
     "Price ($/1K calls)": "Price ($/1K calls)",
     "Price (local currency / USD)": "Price (local currency / USD)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "Ratio Type",
     "Ratio: {{value}}": "Ratio: {{value}}",
     "Ratios synced successfully": "Ratios synced successfully",
+    "Raw expression": "Raw expression",
     "Raw JSON": "Raw JSON",
     "Raw Quota": "Raw Quota",
     "Re-enable on success": "Re-enable on success",
@@ -2801,6 +2821,7 @@
     "Reference Video": "Reference Video",
     "Referral link:": "Referral link:",
     "Referral Program": "Referral Program",
+    "Refine models by provider, group, type, and tags.": "Refine models by provider, group, type, and tags.",
     "Refresh": "Refresh",
     "Refresh Cache": "Refresh Cache",
     "Refresh credential": "Refresh credential",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "Save drawing settings",
     "Save Epay settings": "Save Epay settings",
     "Save failed": "Save failed",
-    "Save Preferences": "Save Preferences",
     "Save failed, please retry": "Save failed, please retry",
     "Save general settings": "Save general settings",
     "Save group ratios": "Save group ratios",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "Save monitoring rules",
     "Save navigation": "Save navigation",
     "Save notice": "Save notice",
+    "Save Preferences": "Save Preferences",
     "Save rate limits": "Save rate limits",
     "Save sensitive words": "Save sensitive words",
     "Save Settings": "Save Settings",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)",
     "Scenario Templates": "Scenario Templates",
     "Scheduled channel tests": "Scheduled channel tests",
+    "scheduling controls": "scheduling controls",
     "Scope": "Scope",
     "Scopes": "Scopes",
     "Search": "Search",
@@ -3035,6 +3057,7 @@
     "Search group names...": "Search group names...",
     "Search groups...": "Search groups...",
     "Search missing models": "Search missing models",
+    "Search model name, provider, endpoint, or tag...": "Search model name, provider, endpoint, or tag...",
     "Search model name...": "Search model name...",
     "Search models": "Search models",
     "Search models or fields...": "Search models or fields...",
@@ -3219,6 +3242,7 @@
     "sources": "sources",
     "Space-separated OAuth scopes": "Space-separated OAuth scopes",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Spark model version, e.g., v2.1 (version number in API URL)",
+    "Special billing expression": "Special billing expression",
     "Special usable group rules": "Special usable group rules",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.",
     "SSRF Protection": "SSRF Protection",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "This page has not been created yet.",
     "This project must be used in compliance with the": "This project must be used in compliance with the",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.",
+    "This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
     "this token group": "this token group",
     "this user group": "this user group",
     "This user has no bindings": "This user has no bindings",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "Unable to generate chat link. Please contact your administrator.",
     "Unable to load groups": "Unable to load groups",
     "Unable to open chat": "Unable to open chat",
+    "Unable to parse structured pricing": "Unable to parse structured pricing",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "Unable to prepare chat link. Please ensure you have an enabled API key.",
     "Unauthorized": "Unauthorized",
     "Unauthorized Access": "Unauthorized Access",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "Upstream prices fetched successfully",
     "Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
     "Upstream Response": "Upstream Response",
+    "upstream services integrated": "upstream services integrated",
     "Upstream Updates": "Upstream Updates",
     "uptime": "uptime",
     "Uptime": "Uptime",

+ 29 - 2
web/default/src/i18n/locales/fr.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "Diffuser une bannière globale aux utilisateurs. Le Markdown est pris en charge.",
     "Broadcast short system notices on the dashboard": "Diffuser de courtes notifications système sur le tableau de bord",
     "Browse and compare": "Parcourir et comparer",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "Découvrez une sélection de modèles IA, comparez les tarifs et les capacités, et choisissez le modèle adapté à chaque scénario.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Jetons budgétaires = jetons max × ratio. Accepte un nombre décimal entre 0,002 et 1. Il est recommandé de rester aligné avec la facturation en amont.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Jetons budgétaires = jetons max × ratio. Accepte un nombre décimal entre 0,1 et 1.",
     "Budget Tokens Ratio": "Ratio de jetons budgétaires",
@@ -527,6 +528,7 @@
     "Cancelled": "Annulé",
     "Cancelled at": "Annulé le",
     "Capture a reusable bundle of models, tags, or endpoints.": "Capturez un ensemble réutilisable de modèles, d'étiquettes ou de points de terminaison.",
+    "Card view": "Vue cartes",
     "Category Name": "Nom de la catégorie",
     "Category name is required": "Le nom de la catégorie est requis",
     "Category name must be less than 50 characters": "Le nom de la catégorie doit contenir moins de 50 caractères",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "Choisissez comment filtrer les domaines",
     "Choose how to filter IP addresses": "Choisissez comment filtrer les adresses IP",
     "Choose the bundle type and define the items inside it.": "Choisissez le type de bundle et définissez les éléments qu'il contient.",
-    "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
     "Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.",
+    "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.",
     "Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.",
     "Classic (Legacy Frontend)": "Classique (Ancien frontend)",
     "Claude": "Claude",
@@ -676,6 +678,9 @@
     "Common Logs": "Journaux courants",
     "Common ports include 25, 465, and 587": "Les ports courants incluent 25, 465 et 587",
     "Common User": "Utilisateur commun",
+    "Community driven, self-hosted, and extensible": "Piloté par la communauté, auto-hébergé et extensible",
+    "compatible API routes": "routes API compatibles",
+    "Compatible API routes for common AI application workflows": "Routes API compatibles pour les workflows courants des applications d'IA",
     "Complete API documentation with multi-language SDK support": "Documentation API complète avec support SDK multilingue",
     "Complete Order": "Compléter la commande",
     "Complete these steps to finish the initial installation.": "Suivez ces étapes pour terminer l'installation initiale.",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "Confirmez votre identité avec l’authentification à deux facteurs avant d’enregistrer une Passkey.",
     "Conflict": "Conflit",
     "Connect": "Connecter",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connectez-vous via OpenAI, Claude, Gemini et d'autres routes API compatibles",
     "Connected to io.net service normally.": "Connexion au service io.net réussie.",
     "Connection error": "Erreur de connexion",
     "Connection failed": "Connexion échouée",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "Mode site de démonstration",
     "Demo Site Mode": "Mode Site de démonstration",
     "Demote": "Rétrograder",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "Déployez votre propre passerelle et commencez à router les requêtes via vos services en amont configurés.",
     "Deployment created successfully": "Déploiement créé avec succès",
     "Deployment details": "Détails du déploiement",
     "Deployment ID": "ID du déploiement",
@@ -1553,10 +1560,12 @@
     "Filter by username": "Filtrer par nom d'utilisateur",
     "Filter by username, name or email...": "Filtrer par nom d'utilisateur, nom ou e-mail...",
     "Filter Dashboard Models": "Filtrer les modèles du tableau de bord",
+    "Filter models by provider, group, type, endpoint, and tags.": "Filtrer les modèles par fournisseur, groupe, type, endpoint et tags.",
     "Filter models by type, endpoint, vendor, group and tags": "Filtrer les modèles par type, point d'accès, fournisseur, groupe et tags",
     "Filter models...": "Filtrer les modèles...",
     "Filter...": "Filtrer...",
     "Filters": "Filtres",
+    "Filters active": "Filtres actifs",
     "Final Consumed": "Consommation finale",
     "Final cost = base × multiplier when conditions match": "Coût final = base × multiplicateur lorsque les conditions correspondent",
     "Final price multiplier (0.95 = 5% discount": "Multiplicateur de prix final (0.95 = 5% de réduction",
@@ -1676,6 +1685,7 @@
     "Group name": "Nom du groupe",
     "Group Name": "Nom du groupe",
     "Group name cannot be changed when editing.": "Le nom du groupe ne peut pas être modifié lors de la modification.",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "Les prix par groupe ne peuvent pas être détaillés car cette expression n'est pas une expression tarifaire par paliers standard.",
     "group ratio": "ratio de groupe",
     "Group Ratio": "Ratio de groupe",
     "Group ratios": "Ratios de groupe",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "Mode de filtre IP",
     "IP Restriction": "Restriction IP",
     "IP Whitelist (supports CIDR)": "Liste blanche IP (supporte CIDR)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "est une passerelle API IA open source pour les déploiements auto-hébergés. Connectez plusieurs services en amont et gérez au même endroit les modèles, les clés, les quotas, les journaux et les politiques de routage.",
     "is less than the configured maximum cache size": "est inférieur à la taille maximale du cache configurée",
     "is the default price; ": "est le prix par défaut ; ",
     "It seems like the page you're looking for": "Il semble que la page que vous recherchez",
@@ -2051,6 +2062,7 @@
     "Model": "Modèle",
     "Model Access": "Accès au modèle",
     "Model Analytics": "Analyse des modèles",
+    "model billing support": "prise en charge de la facturation des modèles",
     "Model Call Analytics": "Analyse des appels de modèles",
     "Model context usage": "Utilisation du contexte du modèle",
     "Model deleted": "Modèle supprimé",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "Ratios des modèles réinitialisés avec succès",
     "Model Regex": "Regex du modèle",
     "Model Regex (one per line)": "Regex du modèle (un par ligne)",
+    "Model Square": "Place des modèles",
+    "Model Tags": "Tags de modèle",
     "Model to use for testing": "Modèle à utiliser pour les tests",
     "Model to use when testing channel connectivity": "Modèle à utiliser lors du test de la connectivité du canal",
     "Model Version *": "Version du modèle *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "Modèles ajoutés avec succès",
     "Models are required": "Les modèles sont requis",
     "Models directory": "Répertoire des modèles",
+    "Models Directory": "Répertoire des modèles",
     "Models fetched successfully": "Modèles récupérés avec succès",
     "Models filled to form": "Modèles remplis pour le formulaire",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Les modèles listés ici n'ajouteront ni ne supprimeront automatiquement les suffixes -thinking / -nothinking.",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "Stratégie multi-clés",
     "Multi-key: Polling rotation": "Multi-clé : Rotation par sondage",
     "Multi-key: Random rotation": "Multi-clé : Rotation aléatoire",
+    "Multi-protocol Compatible": "Compatible multi-protocole",
     "Multi-region deployment for stable global access": "Déploiement multirégional pour un accès mondial stable",
     "Multi-user management with flexible permission allocation": "Gestion multi-utilisateurs avec attribution de permissions flexible",
     "Multiplier": "Multiplicateur",
@@ -2216,6 +2232,7 @@
     "No data available": "Aucune donnée disponible",
     "No deployments available. Create one to get started.": "Aucun déploiement disponible. Créez-en un pour commencer.",
     "No Deployments Found": "Aucun déploiement trouvé",
+    "No description available.": "Aucune description disponible.",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "Aucun niveau de réduction configuré. Cliquez sur « Ajouter un niveau de réduction » pour commencer.",
     "No duplicate keys found": "Aucune clé dupliquée trouvée",
     "No enabled tokens available": "Aucun token activé disponible",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "Ouvrir dans un nouvel onglet",
     "Open menu": "Ouvrir le menu",
     "Open release": "Ouvrir la version",
+    "Open Source": "Open source",
     "Open the io.net console API Keys page": "Ouvrir la page Clés API de la console io.net",
     "Open theme settings": "Ouvrir les paramètres du thème",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "Précédent",
     "Previous branch": "Branche précédente",
     "Previous page": "Page précédente",
+    "Base Price": "Prix de base",
     "Price": "Prix",
     "Price ($/1K calls)": "Prix ($/1K appels)",
     "Price (local currency / USD)": "Prix (devise locale / USD)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "Type de ratio",
     "Ratio: {{value}}": "Ratio : {{value}}",
     "Ratios synced successfully": "Ratios synchronisés avec succès",
+    "Raw expression": "Expression brute",
     "Raw JSON": "JSON brut",
     "Raw Quota": "Quota brut",
     "Re-enable on success": "Réactiver en cas de succès",
@@ -2801,6 +2821,7 @@
     "Reference Video": "Vidéo de référence",
     "Referral link:": "Lien de parrainage :",
     "Referral Program": "Programme de parrainage",
+    "Refine models by provider, group, type, and tags.": "Affinez les modèles par fournisseur, groupe, type et tags.",
     "Refresh": "Actualiser",
     "Refresh Cache": "Actualiser le cache",
     "Refresh credential": "Actualiser l'identifiant",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "Enregistrer les paramètres de dessin",
     "Save Epay settings": "Enregistrer les paramètres Epay",
     "Save failed": "Échec de l'enregistrement",
-    "Save Preferences": "Enregistrer les préférences",
     "Save failed, please retry": "Échec de l'enregistrement, veuillez réessayer",
     "Save general settings": "Enregistrer les paramètres généraux",
     "Save group ratios": "Enregistrer les ratios de groupes",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "Enregistrer les règles de surveillance",
     "Save navigation": "Enregistrer la navigation",
     "Save notice": "Enregistrer l'avis",
+    "Save Preferences": "Enregistrer les préférences",
     "Save rate limits": "Enregistrer les limites de débit",
     "Save sensitive words": "Enregistrer les mots sensibles",
     "Save Settings": "Enregistrer les paramètres",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Scannez ce code QR avec votre application d'authentification (Google Authenticator, Microsoft Authenticator, etc.)",
     "Scenario Templates": "Modèles de scénario",
     "Scheduled channel tests": "Tests de canaux planifiés",
+    "scheduling controls": "contrôles d'ordonnancement",
     "Scope": "Portée",
     "Scopes": "Portées",
     "Search": "Rechercher",
@@ -3035,6 +3057,7 @@
     "Search group names...": "Rechercher des noms de groupe...",
     "Search groups...": "Rechercher des groupes...",
     "Search missing models": "Rechercher les modèles manquants",
+    "Search model name, provider, endpoint, or tag...": "Rechercher un nom de modèle, fournisseur, endpoint ou tag...",
     "Search model name...": "Rechercher le nom du modèle...",
     "Search models": "Rechercher des modèles",
     "Search models or fields...": "Rechercher des modèles ou des champs...",
@@ -3219,6 +3242,7 @@
     "sources": "sources",
     "Space-separated OAuth scopes": "Scopes OAuth séparés par des espaces",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Version du modèle Spark, par exemple v2.1 (numéro de version dans l'URL de l'API)",
+    "Special billing expression": "Expression de facturation spéciale",
     "Special usable group rules": "Règles spéciales de groupes utilisables",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stocke toutes les données dans un seul fichier. Assurez-vous que ce fichier est persisté lors de l'exécution dans des conteneurs.",
     "SSRF Protection": "Protection SSRF",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "Cette page n'a pas encore été créée.",
     "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Cet enregistrement provient d’une instance avant la mise à niveau et n’inclut pas d’audits. Mettez à jour l’instance pour enregistrer l’IP du serveur, l’IP de callback, le moyen de paiement et la version du système.",
+    "This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
     "this token group": "ce groupe de jetons",
     "this user group": "ce groupe d'utilisateurs",
     "This user has no bindings": "Cet utilisateur n'a aucune liaison",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "Impossible de générer le lien de discussion. Veuillez contacter votre administrateur.",
     "Unable to load groups": "Impossible de charger les groupes",
     "Unable to open chat": "Impossible d'ouvrir la discussion",
+    "Unable to parse structured pricing": "Impossible d'analyser la tarification structurée",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "Impossible de préparer le lien de chat. Veuillez vous assurer d'avoir une clé API activée.",
     "Unauthorized": "Non autorisé",
     "Unauthorized Access": "Accès non autorisé",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "Prix amont récupérés avec succès",
     "Upstream ratios fetched successfully": "Ratios en amont récupérés avec succès",
     "Upstream Response": "Réponse amont",
+    "upstream services integrated": "services en amont intégrés",
     "Upstream Updates": "Mises à jour en amont",
     "uptime": "disponibilité",
     "Uptime": "Temps de fonctionnement",

+ 29 - 2
web/default/src/i18n/locales/ja.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "ユーザーにグローバルバナーをブロードキャストします。Markdownがサポートされています。",
     "Broadcast short system notices on the dashboard": "ダッシュボードに短いシステム通知をブロードキャストします",
     "Browse and compare": "参照と比較",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "厳選された AI モデルを見つけ、価格と機能を比較し、あらゆるシナリオに適したモデルを選択できます。",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "予算トークン = 最大トークン × 比率。0.002から1までの小数を指定できます。アップストリームの請求と一致させることを推奨します。",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "予算トークン = 最大トークン × 比率。0.1から1までの小数を指定できます。",
     "Budget Tokens Ratio": "予算トークン比率",
@@ -527,6 +528,7 @@
     "Cancelled": "キャンセル",
     "Cancelled at": "キャンセル日時",
     "Capture a reusable bundle of models, tags, or endpoints.": "モデル、タグ、またはエンドポイントの再利用可能なバンドルを保存。",
+    "Card view": "カード表示",
     "Category Name": "分類名称",
     "Category name is required": "カテゴリ名は必須です",
     "Category name must be less than 50 characters": "カテゴリ名は50文字以内にしてください",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "ドメインをフィルタリングする方法を選択してください",
     "Choose how to filter IP addresses": "IPアドレスをフィルタリングする方法を選択してください",
     "Choose the bundle type and define the items inside it.": "バンドルタイプを選択し、その中のアイテムを定義してください。",
-    "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
     "Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。",
+    "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。",
     "Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。",
     "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)",
     "Claude": "Claude",
@@ -676,6 +678,9 @@
     "Common Logs": "一般的なログ",
     "Common ports include 25, 465, and 587": "一般的なポートには 25, 465, 587 が含まれます",
     "Common User": "一般ユーザー",
+    "Community driven, self-hosted, and extensible": "コミュニティ主導、セルフホスト可能、拡張可能",
+    "compatible API routes": "互換APIルート",
+    "Compatible API routes for common AI application workflows": "一般的なAIアプリケーションワークフロー向けの互換APIルート",
     "Complete API documentation with multi-language SDK support": "多言語SDKをサポートする完全なAPIドキュメント",
     "Complete Order": "手動チャージ",
     "Complete these steps to finish the initial installation.": "初期インストールを完了するには、これらの手順を完了してください。",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "パスキーを登録する前に、二要素認証で本人確認を行ってください。",
     "Conflict": "競合",
     "Connect": "接続",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "OpenAI、Claude、Gemini、その他の互換APIルートから接続",
     "Connected to io.net service normally.": "io.net サービスに正常に接続しました。",
     "Connection error": "接続エラー",
     "Connection failed": "接続に失敗しました",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "デモサイトモード",
     "Demo Site Mode": "デモサイトモード",
     "Demote": "降格",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "独自のゲートウェイをデプロイし、設定済みのアップストリームサービスへリクエストをルーティングできます。",
     "Deployment created successfully": "デプロイを作成しました",
     "Deployment details": "デプロイメントの詳細",
     "Deployment ID": "デプロイ ID",
@@ -1553,10 +1560,12 @@
     "Filter by username": "ユーザー名でフィルター",
     "Filter by username, name or email...": "ユーザー名、名前またはメールアドレスでフィルター...",
     "Filter Dashboard Models": "ダッシュボードモデルをフィルタリング",
+    "Filter models by provider, group, type, endpoint, and tags.": "プロバイダー、グループ、タイプ、エンドポイント、タグでモデルを絞り込みます。",
     "Filter models by type, endpoint, vendor, group and tags": "タイプ、エンドポイント、ベンダー、グループ、タグでモデルをフィルタリング",
     "Filter models...": "モデルをフィルタリング...",
     "Filter...": "フィルター…",
     "Filters": "フィルター",
+    "Filters active": "フィルター有効",
     "Final Consumed": "最終消費",
     "Final cost = base × multiplier when conditions match": "条件に一致する場合 最終費用 = 基準 × 倍率",
     "Final price multiplier (0.95 = 5% discount": "最終価格乗数 (0.95 = 5%割引",
@@ -1676,6 +1685,7 @@
     "Group name": "グループ名",
     "Group Name": "グループ名",
     "Group name cannot be changed when editing.": "編集時はグループ名を変更できません。",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "この式は標準の段階制料金式ではないため、グループ別価格を展開できません。",
     "group ratio": "グループ倍率",
     "Group Ratio": "グループ倍率",
     "Group ratios": "グループ比率",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "IP フィルターモード",
     "IP Restriction": "IP制限",
     "IP Whitelist (supports CIDR)": "IP ホワイトリスト(CIDR対応)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "はセルフホスト運用向けのオープンソースAI APIゲートウェイです。複数のアップストリームサービスを接続し、モデル、キー、クォータ、ログ、ルーティングポリシーを一元管理できます。",
     "is less than the configured maximum cache size": "設定された最大キャッシュサイズより小さい",
     "is the default price; ": "はデフォルト価格です; ",
     "It seems like the page you're looking for": "お探しのページは",
@@ -2051,6 +2062,7 @@
     "Model": "モデル",
     "Model Access": "モデルアクセス",
     "Model Analytics": "モデル分析",
+    "model billing support": "モデル課金対応",
     "Model Call Analytics": "モデル呼び出し分析",
     "Model context usage": "モデルのコンテキスト使用量",
     "Model deleted": "モデルが削除されました",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "モデル比率が正常にリセットされました",
     "Model Regex": "モデル正規表現",
     "Model Regex (one per line)": "モデル正規表現(1行に1つ)",
+    "Model Square": "モデル広場",
+    "Model Tags": "モデルタグ",
     "Model to use for testing": "テストに使用するモデル",
     "Model to use when testing channel connectivity": "チャネル接続性をテストする際に使用するモデル",
     "Model Version *": "モデルバージョン *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "モデルが正常に追加されました",
     "Models are required": "モデルが必要です",
     "Models directory": "モデルディレクトリ",
+    "Models Directory": "モデルディレクトリ",
     "Models fetched successfully": "モデルが正常に取得されました",
     "Models filled to form": "フォームにモデルが記入されました",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "ここに記載されたモデルは、-thinking / -nothinking サフィックスの自動付与・削除を行いません。",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "マルチキー戦略",
     "Multi-key: Polling rotation": "マルチキー:ポーリングローテーション",
     "Multi-key: Random rotation": "マルチキー:ランダムローテーション",
+    "Multi-protocol Compatible": "マルチプロトコル互換",
     "Multi-region deployment for stable global access": "安定したグローバルアクセスを実現するマルチリージョンデプロイメント",
     "Multi-user management with flexible permission allocation": "柔軟な権限割り当てが可能なマルチユーザー管理",
     "Multiplier": "乗数",
@@ -2216,6 +2232,7 @@
     "No data available": "データがありません",
     "No deployments available. Create one to get started.": "利用可能なデプロイメントがありません。開始するには1つ作成してください。",
     "No Deployments Found": "デプロイメントが見つかりません",
+    "No description available.": "説明はありません。",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "割引ティアは設定されていません。「割引ティアを追加」をクリックして開始してください。",
     "No duplicate keys found": "重複キーが見つかりませんでした",
     "No enabled tokens available": "有効なトークンがありません",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "新しいタブで開く",
     "Open menu": "メニューを開く",
     "Open release": "リリースを開く",
+    "Open Source": "オープンソース",
     "Open the io.net console API Keys page": "io.netコンソールAPIキーページを開く",
     "Open theme settings": "テーマ設定を開く",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "前へ",
     "Previous branch": "前のブランチ",
     "Previous page": "前のページ",
+    "Base Price": "基本価格",
     "Price": "価格",
     "Price ($/1K calls)": "価格($/1K 回)",
     "Price (local currency / USD)": "価格 (現地通貨 / USD)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "比率タイプ",
     "Ratio: {{value}}": "倍率:{{value}}",
     "Ratios synced successfully": "比率が正常に同期されました",
+    "Raw expression": "元の式",
     "Raw JSON": "生 JSON",
     "Raw Quota": "元のクォータ",
     "Re-enable on success": "成功時に再有効化",
@@ -2801,6 +2821,7 @@
     "Reference Video": "参照動画",
     "Referral link:": "紹介リンク:",
     "Referral Program": "紹介プログラム",
+    "Refine models by provider, group, type, and tags.": "プロバイダー、グループ、タイプ、タグでモデルを絞り込みます。",
     "Refresh": "更新",
     "Refresh Cache": "キャッシュ更新",
     "Refresh credential": "認証情報を更新",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "描画設定を保存",
     "Save Epay settings": "Epay設定を保存",
     "Save failed": "保存に失敗しました",
-    "Save Preferences": "設定を保存",
     "Save failed, please retry": "保存に失敗しました。もう一度お試しください",
     "Save general settings": "一般設定を保存",
     "Save group ratios": "グループ比率を保存",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "監視ルールを保存",
     "Save navigation": "ナビゲーションを保存",
     "Save notice": "通知を保存",
+    "Save Preferences": "設定を保存",
     "Save rate limits": "レート制限を保存",
     "Save sensitive words": "敏感な言葉を保存",
     "Save Settings": "設定を保存",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "このQRコードを認証アプリ(Google Authenticator、Microsoft Authenticatorなど)でスキャンしてください。",
     "Scenario Templates": "シナリオテンプレート",
     "Scheduled channel tests": "スケジュールされたチャネルテスト",
+    "scheduling controls": "スケジューリング制御",
     "Scope": "スコープ",
     "Scopes": "スコープ",
     "Search": "検索",
@@ -3035,6 +3057,7 @@
     "Search group names...": "グループ名を検索...",
     "Search groups...": "グループを検索...",
     "Search missing models": "不足しているモデルを検索",
+    "Search model name, provider, endpoint, or tag...": "モデル名、プロバイダー、エンドポイント、タグを検索...",
     "Search model name...": "モデル名を検索...",
     "Search models": "モデルを検索",
     "Search models or fields...": "モデルまたはフィールドを検索...",
@@ -3219,6 +3242,7 @@
     "sources": "ソース",
     "Space-separated OAuth scopes": "スペース区切りのOAuthスコープ",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Sparkモデルバージョン(例:v2.1、API URLのバージョン番号)",
+    "Special billing expression": "特殊な課金式",
     "Special usable group rules": "特別な使用可能グループルール",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite はすべてのデータを単一ファイルに保存します。コンテナで実行する場合は、ファイルが永続化されていることを確認してください。",
     "SSRF Protection": "SSRF保護",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "このページはまだ作成されていません。",
     "This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "古いバージョンのインスタンスがこの記録を書き込み、監査情報がありません。最新に更新し、サーバーIP・コールバックIP・支払方法・OSバージョンの記録を有効にしてください。",
+    "This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
     "this token group": "このトークングループ",
     "this user group": "このユーザーグループ",
     "This user has no bindings": "このユーザーには連携がありません",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "チャットリンクを生成できません。管理者にご連絡ください。",
     "Unable to load groups": "グループをロードできません",
     "Unable to open chat": "チャットを開けません",
+    "Unable to parse structured pricing": "構造化された価格を解析できません",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "チャットリンクを準備できません。有効な API キーが設定されていることを確認してください。",
     "Unauthorized": "未認証",
     "Unauthorized Access": "不正アクセス",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "上流価格を正常に取得しました",
     "Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました",
     "Upstream Response": "アップストリームレスポンス",
+    "upstream services integrated": "アップストリームサービス連携",
     "Upstream Updates": "アップストリーム更新",
     "uptime": "稼働時間",
     "Uptime": "稼働時間",

+ 29 - 2
web/default/src/i18n/locales/ru.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "Транслировать глобальный баннер пользователям. Поддерживается Markdown.",
     "Broadcast short system notices on the dashboard": "Транслировать короткие системные уведомления на панели управления",
     "Browse and compare": "Просмотр и сравнение",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "Откройте для себя подобранные AI-модели, сравнивайте цены и возможности и выбирайте подходящую модель для каждого сценария.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Бюджетные токены = макс. токены × соотношение. Принимает десятичное число от 0.002 до 1. Рекомендуется поддерживать в соответствии с биллингом вышестоящего провайдера.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Бюджетные токены = макс. токены × соотношение. Принимает десятичное число от 0.1 до 1.",
     "Budget Tokens Ratio": "Соотношение бюджетных токенов",
@@ -527,6 +528,7 @@
     "Cancelled": "Отменено",
     "Cancelled at": "Отменено",
     "Capture a reusable bundle of models, tags, or endpoints.": "Создайте повторно используемый набор моделей, тегов или конечных точек.",
+    "Card view": "Карточки",
     "Category Name": "Название категории",
     "Category name is required": "Название категории обязательно",
     "Category name must be less than 50 characters": "Название категории должно содержать менее 50 символов",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "Выберите, как фильтровать домены",
     "Choose how to filter IP addresses": "Выберите, как фильтровать IP-адреса",
     "Choose the bundle type and define the items inside it.": "Выберите тип пакета и определите элементы внутри него.",
-    "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
     "Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.",
+    "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.",
     "Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.",
     "Classic (Legacy Frontend)": "Классический (Старый интерфейс)",
     "Claude": "Клод",
@@ -676,6 +678,9 @@
     "Common Logs": "Общие журналы",
     "Common ports include 25, 465, and 587": "Распространенные порты включают 25, 465 и 587",
     "Common User": "Обычный пользователь",
+    "Community driven, self-hosted, and extensible": "Развивается сообществом, поддерживает самостоятельное размещение и расширение",
+    "compatible API routes": "совместимых API-маршрутов",
+    "Compatible API routes for common AI application workflows": "Совместимые API-маршруты для типовых сценариев ИИ-приложений",
     "Complete API documentation with multi-language SDK support": "Полная документация API с поддержкой SDK на нескольких языках",
     "Complete Order": "Вывод заказа",
     "Complete these steps to finish the initial installation.": "Выполните эти шаги, чтобы завершить начальную установку.",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "Подтвердите свою личность с помощью двухфакторной аутентификации перед регистрацией Passkey.",
     "Conflict": "Противоречие",
     "Connect": "Подключение",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Подключайтесь через OpenAI, Claude, Gemini и другие совместимые API-маршруты",
     "Connected to io.net service normally.": "Соединение с сервисом io.net установлено.",
     "Connection error": "Ошибка соединения",
     "Connection failed": "Не удалось подключиться",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "Режим демо-сайта",
     "Demo Site Mode": "Режим демонстрационного сайта",
     "Demote": "Понизить версию",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "Разверните собственный шлюз и начните маршрутизировать запросы через настроенные вышестоящие сервисы.",
     "Deployment created successfully": "Развертывание создано успешно",
     "Deployment details": "Детали развертывания",
     "Deployment ID": "ID развертывания",
@@ -1553,10 +1560,12 @@
     "Filter by username": "Фильтр по имени пользователя",
     "Filter by username, name or email...": "Фильтр по имени пользователя, имени или email...",
     "Filter Dashboard Models": "Фильтровать модели панели управления",
+    "Filter models by provider, group, type, endpoint, and tags.": "Фильтруйте модели по поставщику, группе, типу, endpoint и тегам.",
     "Filter models by type, endpoint, vendor, group and tags": "Фильтровать модели по типу, точке доступа, поставщику, группе и тегам",
     "Filter models...": "Фильтровать модели...",
     "Filter...": "Фильтр...",
     "Filters": "Фильтры",
+    "Filters active": "Фильтры активны",
     "Final Consumed": "Итоговое потребление",
     "Final cost = base × multiplier when conditions match": "Итоговая стоимость = база × множитель, если условия совпадают",
     "Final price multiplier (0.95 = 5% discount": "Конечный множитель цены (0.95 = скидка 5%",
@@ -1676,6 +1685,7 @@
     "Group name": "Имя группы",
     "Group Name": "Имя группы",
     "Group name cannot be changed when editing.": "Имя группы нельзя изменить при редактировании.",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "Цены по группам нельзя развернуть, потому что это не стандартное выражение тарифов по уровням.",
     "group ratio": "коэффициент группы",
     "Group Ratio": "Групповой коэффициент",
     "Group ratios": "Соотношения группы",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "Режим фильтрации IP",
     "IP Restriction": "Ограничение IP",
     "IP Whitelist (supports CIDR)": "Белый список IP (поддерживает CIDR)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "— это open-source API-шлюз для ИИ, предназначенный для самостоятельного размещения. Подключайте несколько вышестоящих сервисов и управляйте моделями, ключами, квотами, журналами и политиками маршрутизации в одном месте.",
     "is less than the configured maximum cache size": "меньше настроенного максимального размера кэша",
     "is the default price; ": "— цена по умолчанию; ",
     "It seems like the page you're looking for": "Похоже, страница, которую вы ищете",
@@ -2051,6 +2062,7 @@
     "Model": "Модель",
     "Model Access": "Доступ к моделям",
     "Model Analytics": "Аналитика моделей",
+    "model billing support": "поддержка биллинга моделей",
     "Model Call Analytics": "Аналитика вызовов моделей",
     "Model context usage": "Использование контекста модели",
     "Model deleted": "Модель удалена",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "Соотношения моделей успешно сброшены",
     "Model Regex": "Регулярное выражение модели",
     "Model Regex (one per line)": "Регулярное выражение модели (по одному на строку)",
+    "Model Square": "Витрина моделей",
+    "Model Tags": "Теги моделей",
     "Model to use for testing": "Модель для использования при тестировании",
     "Model to use when testing channel connectivity": "Модель для использования при тестировании подключения канала",
     "Model Version *": "Версия модели *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "Модели успешно добавлены",
     "Models are required": "Требуются модели",
     "Models directory": "Каталог моделей",
+    "Models Directory": "Каталог моделей",
     "Models fetched successfully": "Модели успешно получены",
     "Models filled to form": "Модели заполнены в форму",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Модели из этого списка не будут автоматически добавлять или удалять суффиксы -thinking / -nothinking.",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "Стратегия нескольких ключей",
     "Multi-key: Polling rotation": "Мульти-ключ: Циклическая ротация",
     "Multi-key: Random rotation": "Мульти-ключ: Случайная ротация",
+    "Multi-protocol Compatible": "Совместимо с несколькими протоколами",
     "Multi-region deployment for stable global access": "Мультирегиональное развертывание для стабильного глобального доступа",
     "Multi-user management with flexible permission allocation": "Многопользовательское управление с гибким распределением разрешений",
     "Multiplier": "Множитель",
@@ -2216,6 +2232,7 @@
     "No data available": "Нет доступных данных",
     "No deployments available. Create one to get started.": "Нет доступных развертываний. Создайте одно, чтобы начать.",
     "No Deployments Found": "Развертывания не найдены",
+    "No description available.": "Описание отсутствует.",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "Не настроены уровни скидок. Нажмите \"Добавить уровень скидки\", чтобы начать.",
     "No duplicate keys found": "Дубликаты ключей не найдены",
     "No enabled tokens available": "Нет доступных активных токенов",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "Открыть в новой вкладке",
     "Open menu": "Открыть меню",
     "Open release": "Открыть выпуск",
+    "Open Source": "Открытый исходный код",
     "Open the io.net console API Keys page": "Открыть страницу ключей API консоли io.net",
     "Open theme settings": "Открыть настройки темы",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "Предыдущий шаг",
     "Previous branch": "Предыдущая ветка",
     "Previous page": "Предыдущая страница",
+    "Base Price": "Базовая цена",
     "Price": "Цена",
     "Price ($/1K calls)": "Цена ($/1K вызовов)",
     "Price (local currency / USD)": "Цена (местная валюта / USD)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "Тип соотношения",
     "Ratio: {{value}}": "Коэффициент: {{value}}",
     "Ratios synced successfully": "Соотношения успешно синхронизированы",
+    "Raw expression": "Исходное выражение",
     "Raw JSON": "Сырой JSON",
     "Raw Quota": "Исходная квота",
     "Re-enable on success": "Повторно включить при успехе",
@@ -2801,6 +2821,7 @@
     "Reference Video": "Эталонное видео",
     "Referral link:": "Реферальная ссылка:",
     "Referral Program": "Реферальная программа",
+    "Refine models by provider, group, type, and tags.": "Уточняйте список моделей по поставщику, группе, типу и тегам.",
     "Refresh": "Обновить",
     "Refresh Cache": "Обновить кэш",
     "Refresh credential": "Обновить учётные данные",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "Сохранить настройки рисования",
     "Save Epay settings": "Сохранить настройки Epay",
     "Save failed": "Не удалось сохранить",
-    "Save Preferences": "Сохранить настройки",
     "Save failed, please retry": "Не удалось сохранить, попробуйте снова",
     "Save general settings": "Сохранить общие настройки",
     "Save group ratios": "Сохранить коэффициенты групп",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "Сохранить правила мониторинга",
     "Save navigation": "Сохранить навигацию",
     "Save notice": "Сохранить уведомление",
+    "Save Preferences": "Сохранить настройки",
     "Save rate limits": "Сохранить лимиты скорости",
     "Save sensitive words": "Сохранить чувствительные слова",
     "Save Settings": "Сохранить настройки",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора (Google Authenticator, Microsoft Authenticator и т.д.)",
     "Scenario Templates": "Шаблоны сценариев",
     "Scheduled channel tests": "Запланированные тесты канала",
+    "scheduling controls": "механизмов управления маршрутизацией",
     "Scope": "Область",
     "Scopes": "Области доступа",
     "Search": "Поиск",
@@ -3035,6 +3057,7 @@
     "Search group names...": "Поиск названий групп...",
     "Search groups...": "Поиск групп...",
     "Search missing models": "Поиск отсутствующих моделей",
+    "Search model name, provider, endpoint, or tag...": "Поиск по названию модели, поставщику, endpoint или тегу...",
     "Search model name...": "Поиск имени модели...",
     "Search models": "Поиск моделей",
     "Search models or fields...": "Поиск моделей или полей...",
@@ -3219,6 +3242,7 @@
     "sources": "источники",
     "Space-separated OAuth scopes": "Области доступа OAuth, разделенные пробелами",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Версия модели Spark, например, v2.1 (номер версии в URL API)",
+    "Special billing expression": "Специальное выражение тарификации",
     "Special usable group rules": "Специальные правила для групп с доступом",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite хранит все данные в одном файле. Убедитесь, что файл сохраняется при работе в контейнерах.",
     "SSRF Protection": "Защита от SSRF",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "Эта страница еще не создана.",
     "This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Запись создана экземпляром до обновления и не содержит сведений аудита. Обновите экземпляр, чтобы фиксировать IP сервера, IP callback, способ оплаты и версию ОС.",
+    "This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
     "this token group": "эта группа токенов",
     "this user group": "эта группа пользователей",
     "This user has no bindings": "У этого пользователя нет привязок",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "Не удалось сгенерировать ссылку для чата. Пожалуйста, свяжитесь с вашим администратором.",
     "Unable to load groups": "Не удалось загрузить группы",
     "Unable to open chat": "Не удалось открыть чат",
+    "Unable to parse structured pricing": "Не удалось разобрать структурированные цены",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "Не удается подготовить ссылку для чата. Убедитесь, что у вас есть активированный API-ключ.",
     "Unauthorized": "Не авторизован",
     "Unauthorized Access": "Несанкционированный доступ",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "Цены провайдера успешно получены",
     "Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены",
     "Upstream Response": "Ответ Upstream",
+    "upstream services integrated": "интеграций с вышестоящими сервисами",
     "Upstream Updates": "Обновления вышестоящих моделей",
     "uptime": "время бесперебойной работы",
     "Uptime": "Время работы",

+ 29 - 2
web/default/src/i18n/locales/vi.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "Phát một biểu ngữ toàn cầu đến người dùng. Hỗ trợ Markdown.",
     "Broadcast short system notices on the dashboard": "Phát các thông báo hệ thống ngắn trên bảng điều khiển",
     "Browse and compare": "Duyệt và so sánh",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "Khám phá các mô hình AI được tuyển chọn, so sánh giá và khả năng, rồi chọn mô hình phù hợp cho từng kịch bản.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "Số token ngân sách = số token tối đa × tỷ lệ. Chấp nhận một số thập phân từ 0.002 đến 1. Khuyến nghị nên giữ cho phù hợp với cách tính phí của nhà cung cấp.",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "Số token ngân sách = số token tối đa × tỷ lệ. Chấp nhận một số thập phân từ 0.1 đến 1.",
     "Budget Tokens Ratio": "Tỷ lệ Mã thông báo Ngân sách",
@@ -527,6 +528,7 @@
     "Cancelled": "Đã hủy",
     "Cancelled at": "Đã hủy lúc",
     "Capture a reusable bundle of models, tags, or endpoints.": "Đóng gói một bộ có thể tái sử dụng gồm các mô hình, thẻ hoặc điểm cuối.",
+    "Card view": "Dạng thẻ",
     "Category Name": "Tên danh mục",
     "Category name is required": "Tên danh mục là bắt buộc",
     "Category name must be less than 50 characters": "Tên danh mục phải ít hơn 50 ký tự",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "Chọn cách lọc tên miền",
     "Choose how to filter IP addresses": "Chọn cách lọc địa chỉ IP",
     "Choose the bundle type and define the items inside it.": "Chọn loại gói và định nghĩa các mục bên trong nó.",
-    "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
     "Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.",
+    "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.",
     "Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.",
     "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)",
     "Claude": "Claude",
@@ -676,6 +678,9 @@
     "Common Logs": "Logarit thập phân",
     "Common ports include 25, 465, and 587": "Các cổng phổ biến bao gồm 25, 465 và 587",
     "Common User": "Người dùng thông thường",
+    "Community driven, self-hosted, and extensible": "Do cộng đồng phát triển, tự lưu trữ và có thể mở rộng",
+    "compatible API routes": "tuyến API tương thích",
+    "Compatible API routes for common AI application workflows": "Các tuyến API tương thích cho quy trình ứng dụng AI phổ biến",
     "Complete API documentation with multi-language SDK support": "Tài liệu API đầy đủ với hỗ trợ SDK đa ngôn ngữ",
     "Complete Order": "Hoàn thành đơn hàng",
     "Complete these steps to finish the initial installation.": "Hoàn thành các bước này để hoàn tất quá trình cài đặt ban đầu.",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "Hãy xác minh danh tính bằng Xác thực hai yếu tố trước khi đăng ký Passkey.",
     "Conflict": "Xung đột",
     "Connect": "Kết nối",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Kết nối qua OpenAI, Claude, Gemini và các tuyến API tương thích khác",
     "Connected to io.net service normally.": "Đã kết nối bình thường tới dịch vụ io.net.",
     "Connection error": "Lỗi kết nối",
     "Connection failed": "Kết nối thất bại",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "Chế độ trang demo",
     "Demo Site Mode": "Chế độ trang Demo",
     "Demote": "Giáng chức",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "Triển khai cổng riêng của bạn và bắt đầu định tuyến yêu cầu qua các dịch vụ thượng nguồn đã cấu hình.",
     "Deployment created successfully": "Tạo triển khai thành công",
     "Deployment details": "Chi tiết triển khai",
     "Deployment ID": "ID triển khai",
@@ -1553,10 +1560,12 @@
     "Filter by username": "Lọc theo tên người dùng",
     "Filter by username, name or email...": "Lọc theo tên người dùng, tên hoặc email...",
     "Filter Dashboard Models": "Lọc Mô hình Bảng điều khiển",
+    "Filter models by provider, group, type, endpoint, and tags.": "Lọc mô hình theo nhà cung cấp, nhóm, loại, endpoint và thẻ.",
     "Filter models by type, endpoint, vendor, group and tags": "Lọc mô hình theo loại, endpoint, nhà cung cấp, nhóm và thẻ",
     "Filter models...": "Lọc mô hình...",
     "Filter...": "Lọc...",
     "Filters": "Bộ lọc",
+    "Filters active": "Bộ lọc đang bật",
     "Final Consumed": "Tiêu thụ cuối cùng",
     "Final cost = base × multiplier when conditions match": "Chi phí cuối = cơ sở × hệ số khi thỏa điều kiện",
     "Final price multiplier (0.95 = 5% discount": "Hệ số nhân giá cuối cùng (0.95 = giảm giá 5%)",
@@ -1676,6 +1685,7 @@
     "Group name": "Tên nhóm",
     "Group Name": "Tên Nhóm",
     "Group name cannot be changed when editing.": "Tên nhóm không thể thay đổi khi chỉnh sửa.",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "Không thể mở rộng giá theo nhóm vì biểu thức này không phải là biểu thức giá theo bậc tiêu chuẩn.",
     "group ratio": "tỷ lệ nhóm",
     "Group Ratio": "Tỷ lệ nhóm",
     "Group ratios": "Tỷ lệ nhóm",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "Lọc IP",
     "IP Restriction": "Giới hạn IP",
     "IP Whitelist (supports CIDR)": "Danh sách trắng IP (hỗ trợ CIDR)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "là cổng API AI mã nguồn mở dành cho triển khai tự lưu trữ. Kết nối nhiều dịch vụ thượng nguồn, quản lý mô hình, khóa, hạn mức, nhật ký và chính sách định tuyến tại một nơi.",
     "is less than the configured maximum cache size": "nhỏ hơn kích thước bộ nhớ đệm tối đa đã cấu hình",
     "is the default price; ": "là giá mặc định; ",
     "It seems like the page you're looking for": "Có vẻ như trang bạn đang tìm kiếm",
@@ -2051,6 +2062,7 @@
     "Model": "Mô hình",
     "Model Access": "Truy cập mô hình",
     "Model Analytics": "Phân tích mô hình",
+    "model billing support": "hỗ trợ tính phí mô hình",
     "Model Call Analytics": "Phân tích lượt gọi mô hình",
     "Model context usage": "Sử dụng ngữ cảnh mô hình",
     "Model deleted": "Đã xóa mô hình",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "Tỷ lệ mô hình đã được đặt lại thành công",
     "Model Regex": "Regex mô hình",
     "Model Regex (one per line)": "Regex mô hình (mỗi dòng một mục)",
+    "Model Square": "Quảng trường mô hình",
+    "Model Tags": "Thẻ mô hình",
     "Model to use for testing": "Mô hình dùng để kiểm thử",
     "Model to use when testing channel connectivity": "Mô hình để sử dụng khi kiểm tra kết nối kênh",
     "Model Version *": "Phiên bản mô hình *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "Đã thêm mô hình thành công",
     "Models are required": "Các mô hình được yêu cầu",
     "Models directory": "Thư mục mô hình",
+    "Models Directory": "Danh mục mô hình",
     "Models fetched successfully": "Các mô hình đã được tải thành công",
     "Models filled to form": "Mô hình đã được điền vào biểu mẫu",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "Các mô hình được liệt kê ở đây sẽ không tự động thêm hoặc xóa hậu tố -thinking / -nothinking.",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "Chiến lược đa khóa",
     "Multi-key: Polling rotation": "Đa khóa: Xoay vòng tuần tự",
     "Multi-key: Random rotation": "Đa khóa: Xoay vòng ngẫu nhiên",
+    "Multi-protocol Compatible": "Tương thích đa giao thức",
     "Multi-region deployment for stable global access": "Triển khai đa khu vực để truy cập toàn cầu ổn định",
     "Multi-user management with flexible permission allocation": "Quản lý nhiều người dùng với phân bổ quyền linh hoạt",
     "Multiplier": "Hệ số nhân",
@@ -2216,6 +2232,7 @@
     "No data available": "Không có dữ liệu",
     "No deployments available. Create one to get started.": "Không có triển khai nào khả dụng. Tạo một cái để bắt đầu.",
     "No Deployments Found": "Không tìm thấy triển khai nào",
+    "No description available.": "Chưa có mô tả.",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "Chưa cấu hình cấp chiết khấu nào. Nhấp vào \"Thêm cấp chiết khấu\" để bắt đầu.",
     "No duplicate keys found": "Không tìm thấy khóa trùng lặp",
     "No enabled tokens available": "Không có token nào được kích hoạt",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "Mở trong tab mới",
     "Open menu": "Mở menu",
     "Open release": "Phát hành mở",
+    "Open Source": "Mã nguồn mở",
     "Open the io.net console API Keys page": "Mở trang Khóa API của console io.net",
     "Open theme settings": "Mở cài đặt giao diện",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "Trước",
     "Previous branch": "Nhánh trước",
     "Previous page": "Trang trước",
+    "Base Price": "Giá cơ bản",
     "Price": "Giá",
     "Price ($/1K calls)": "Giá ($/1K lượt gọi)",
     "Price (local currency / USD)": "Giá (tiền tệ địa phương / USD)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "Rate type",
     "Ratio: {{value}}": "Tỷ lệ: {{value}}",
     "Ratios synced successfully": "Tỷ lệ đã đồng bộ thành công",
+    "Raw expression": "Biểu thức gốc",
     "Raw JSON": "JSON thô",
     "Raw Quota": "Hạn mức gốc",
     "Re-enable on success": "Kích hoạt lại khi thành công",
@@ -2801,6 +2821,7 @@
     "Reference Video": "Video tham chiếu",
     "Referral link:": "Liên kết giới thiệu:",
     "Referral Program": "Chương trình Giới thiệu",
+    "Refine models by provider, group, type, and tags.": "Tinh chỉnh mô hình theo nhà cung cấp, nhóm, loại và thẻ.",
     "Refresh": "Làm mới",
     "Refresh Cache": "Làm mới bộ nhớ đệm",
     "Refresh credential": "Làm mới thông tin xác thực",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "Lưu cài đặt bản vẽ",
     "Save Epay settings": "Lưu cài đặt Epay",
     "Save failed": "Lưu thất bại",
-    "Save Preferences": "Lưu tùy chọn",
     "Save failed, please retry": "Lưu thất bại, vui lòng thử lại",
     "Save general settings": "Lưu cài đặt chung",
     "Save group ratios": "Lưu tỷ lệ nhóm",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "Lưu quy tắc giám sát",
     "Save navigation": "Lưu điều hướng",
     "Save notice": "Lưu thông báo",
+    "Save Preferences": "Lưu tùy chọn",
     "Save rate limits": "Lưu giới hạn tốc độ",
     "Save sensitive words": "Lưu từ nhạy cảm",
     "Save Settings": "Lưu Cài đặt",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Quét mã QR này bằng ứng dụng xác thực của bạn (Google Authenticator, Microsoft Authenticator, v.v.)",
     "Scenario Templates": "Mẫu kịch bản",
     "Scheduled channel tests": "Kiểm tra kênh theo lịch trình",
+    "scheduling controls": "điều khiển điều phối",
     "Scope": "Phạm vi",
     "Scopes": "Phạm vi",
     "Search": "Tìm kiếm",
@@ -3035,6 +3057,7 @@
     "Search group names...": "Tìm kiếm tên nhóm...",
     "Search groups...": "Searching for group...",
     "Search missing models": "Tìm kiếm mô hình bị thiếu",
+    "Search model name, provider, endpoint, or tag...": "Tìm tên mô hình, nhà cung cấp, endpoint hoặc thẻ...",
     "Search model name...": "Tìm kiếm tên mẫu...",
     "Search models": "Tìm kiếm mô hình",
     "Search models or fields...": "Tìm kiếm mô hình hoặc trường...",
@@ -3219,6 +3242,7 @@
     "sources": "nguồn",
     "Space-separated OAuth scopes": "Phạm vi OAuth phân cách bằng dấu cách",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Phiên bản mô hình Spark, ví dụ: v2.1 (số phiên bản trong URL API)",
+    "Special billing expression": "Biểu thức tính phí đặc biệt",
     "Special usable group rules": "Quy tắc nhóm sử dụng đặc biệt",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite lưu trữ tất cả dữ liệu trong một tệp duy nhất. Đảm bảo tệp được lưu trữ lâu dài khi chạy trong container.",
     "SSRF Protection": "Bảo vệ SSRF",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "Trang này chưa được tạo.",
     "This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Bản ghi này do bản cũ tạo và thiếu thông tin audit. Nâng cấp bản cài để lưu IP máy chủ, IP callback, hình thức thanh toán và phiên bản hệ thống.",
+    "This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
     "this token group": "nhóm token này",
     "this user group": "nhóm người dùng này",
     "This user has no bindings": "Người dùng này không có liên kết nào",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "Không thể tạo liên kết trò chuyện. Vui lòng liên hệ quản trị viên của bạn.",
     "Unable to load groups": "Không thể tải nhóm",
     "Unable to open chat": "Không thể mở trò chuyện",
+    "Unable to parse structured pricing": "Không thể phân tích giá có cấu trúc",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "Không thể chuẩn bị liên kết chat. Vui lòng đảm bảo bạn có khóa API được kích hoạt.",
     "Unauthorized": "Chưa xác thực",
     "Unauthorized Access": "Truy cập trái phép",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "Lấy giá upstream thành công",
     "Upstream ratios fetched successfully": "Đã lấy tỷ lệ upstream thành công",
     "Upstream Response": "Upstream feedback",
+    "upstream services integrated": "dịch vụ thượng nguồn tích hợp",
     "Upstream Updates": "Cập nhật nguồn",
     "uptime": "thời gian hoạt động",
     "Uptime": "Thời gian hoạt động",

+ 29 - 2
web/default/src/i18n/locales/zh.json

@@ -485,6 +485,7 @@
     "Broadcast a global banner to users. Markdown is supported.": "向用户广播全局横幅。支持 Markdown。",
     "Broadcast short system notices on the dashboard": "在仪表板上广播简短的系统通知",
     "Browse and compare": "浏览和比较",
+    "Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.": "探索精选 AI 模型,清晰比较价格与能力,为不同场景选择合适的模型。",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.": "预算令牌 = 最大令牌数 × 比例。接受 0.002 到 1 之间的十进制数。建议与上游计费保持一致。",
     "Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.": "预算令牌 = 最大令牌数 × 比例。接受 0.1 到 1 之间的十进制数。",
     "Budget Tokens Ratio": "预算令牌比例",
@@ -527,6 +528,7 @@
     "Cancelled": "已取消",
     "Cancelled at": "作废于",
     "Capture a reusable bundle of models, tags, or endpoints.": "捕获可重用的模型、标签或端点捆绑包。",
+    "Card view": "卡片视图",
     "Category Name": "分类名称",
     "Category name is required": "分类名称不能为空",
     "Category name must be less than 50 characters": "分类名称不能超过 50 个字符",
@@ -600,8 +602,8 @@
     "Choose how to filter domains": "选择如何过滤域名",
     "Choose how to filter IP addresses": "选择如何过滤 IP 地址",
     "Choose the bundle type and define the items inside it.": "选择捆绑包类型并定义其中的项目。",
-    "Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
     "Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。",
+    "Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。",
     "Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。",
     "Classic (Legacy Frontend)": "经典前端",
     "Claude": "Claude",
@@ -676,6 +678,9 @@
     "Common Logs": "通用日志",
     "Common ports include 25, 465, and 587": "常用端口包括 25、465 和 587",
     "Common User": "普通用户",
+    "Community driven, self-hosted, and extensible": "社区驱动、可自托管、易于扩展",
+    "compatible API routes": "兼容 API 路由",
+    "Compatible API routes for common AI application workflows": "兼容常见 AI 应用工作流的 API 路由",
     "Complete API documentation with multi-language SDK support": "完整的 API 文档,支持多语言 SDK",
     "Complete Order": "补单",
     "Complete these steps to finish the initial installation.": "完成这些步骤以完成初始安装。",
@@ -769,6 +774,7 @@
     "Confirm your identity with Two-factor Authentication before registering a Passkey.": "在注册 Passkey 前请使用两步验证确认你的身份。",
     "Conflict": "矛盾",
     "Connect": "连接",
+    "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "通过 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入",
     "Connected to io.net service normally.": "已正常连接 io.net 服务。",
     "Connection error": "连接错误",
     "Connection failed": "连接失败",
@@ -1003,6 +1009,7 @@
     "Demo site mode": "演示站点模式",
     "Demo Site Mode": "演示站点模式",
     "Demote": "降级",
+    "Deploy your own gateway and start routing requests through your configured upstream services.": "部署你自己的网关,并通过已配置的上游服务开始转发请求。",
     "Deployment created successfully": "创建部署成功",
     "Deployment details": "部署详情",
     "Deployment ID": "部署 ID",
@@ -1553,10 +1560,12 @@
     "Filter by username": "按用户名筛选",
     "Filter by username, name or email...": "按用户名、姓名或邮箱筛选...",
     "Filter Dashboard Models": "筛选仪表板模型",
+    "Filter models by provider, group, type, endpoint, and tags.": "按供应商、分组、类型、端点和标签筛选模型。",
     "Filter models by type, endpoint, vendor, group and tags": "按类型、端点、供应商、分组和标签筛选模型",
     "Filter models...": "筛选模型...",
     "Filter...": "筛选...",
     "Filters": "筛选器",
+    "Filters active": "筛选已启用",
     "Final Consumed": "最终消耗",
     "Final cost = base × multiplier when conditions match": "匹配条件时,最终费用 = 基础费用 × 倍率",
     "Final price multiplier (0.95 = 5% discount": "最终价格乘数 (0.95 = 5% 折扣",
@@ -1676,6 +1685,7 @@
     "Group name": "分组名称",
     "Group Name": "分组名称",
     "Group name cannot be changed when editing.": "编辑时无法更改组名称。",
+    "Group prices cannot be expanded because this expression is not a standard tiered pricing expression.": "该表达式不是标准分档计费表达式,无法展开分组价格。",
     "group ratio": "分组倍率",
     "Group Ratio": "分组倍率",
     "Group ratios": "分组比例",
@@ -1843,6 +1853,7 @@
     "IP Filter Mode": "IP 过滤模式",
     "IP Restriction": "IP 限制",
     "IP Whitelist (supports CIDR)": "IP 白名单(支持 CIDR 表达式)",
+    "is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.": "是一个用于自托管部署的开源 AI API 网关。接入多家上游服务,并集中管理模型、密钥、额度、日志与路由策略。",
     "is less than the configured maximum cache size": "小于配置的最大缓存大小",
     "is the default price; ": "为默认价格;",
     "It seems like the page you're looking for": "您要查找的页面似乎",
@@ -2051,6 +2062,7 @@
     "Model": "模型",
     "Model Access": "模型访问",
     "Model Analytics": "模型数据分析",
+    "model billing support": "模型计费支持",
     "Model Call Analytics": "模型调用分析",
     "Model context usage": "模型上下文用量",
     "Model deleted": "模型已删除",
@@ -2081,6 +2093,8 @@
     "Model ratios reset successfully": "模型比例重置成功",
     "Model Regex": "模型正则",
     "Model Regex (one per line)": "模型正则(每行一个)",
+    "Model Square": "模型广场",
+    "Model Tags": "模型标签",
     "Model to use for testing": "用于测试的模型",
     "Model to use when testing channel connectivity": "测试通道连接时使用的模型",
     "Model Version *": "模型版本 *",
@@ -2093,6 +2107,7 @@
     "Models appended successfully": "模型已追加成功",
     "Models are required": "需要模型",
     "Models directory": "模型目录",
+    "Models Directory": "模型目录",
     "Models fetched successfully": "模型获取成功",
     "Models filled to form": "模型已填充到表单",
     "Models listed here will not automatically append or remove -thinking / -nothinking suffixes.": "此处列出的模型不会自动添加或移除 -thinking / -nothinking 后缀。",
@@ -2126,6 +2141,7 @@
     "Multi-Key Strategy": "多密钥策略",
     "Multi-key: Polling rotation": "多密钥:轮询",
     "Multi-key: Random rotation": "多密钥:随机",
+    "Multi-protocol Compatible": "兼容多协议",
     "Multi-region deployment for stable global access": "多区域部署,实现稳定的全球访问",
     "Multi-user management with flexible permission allocation": "多用户管理,灵活分配权限",
     "Multiplier": "倍率",
@@ -2216,6 +2232,7 @@
     "No data available": "暂无数据",
     "No deployments available. Create one to get started.": "没有可用的部署。创建一个开始吧。",
     "No Deployments Found": "未找到部署",
+    "No description available.": "暂无描述。",
     "No discount tiers configured. Click \"Add discount tier\" to get started.": "未配置折扣等级。点击“添加折扣等级”即可开始使用。",
     "No duplicate keys found": "未发现重复密钥",
     "No enabled tokens available": "当前没有可用的启用令牌",
@@ -2375,6 +2392,7 @@
     "Open in New Tab": "在新标签页中打开",
     "Open menu": "打开菜单",
     "Open release": "打开版本",
+    "Open Source": "开源项目",
     "Open the io.net console API Keys page": "打开 io.net 控制台 API 密钥页面",
     "Open theme settings": "打开主题设置",
     "OpenAI": "OpenAI",
@@ -2637,6 +2655,7 @@
     "Previous": "上一步",
     "Previous branch": "上一分支",
     "Previous page": "上一页",
+    "Base Price": "基础价格",
     "Price": "价格",
     "Price ($/1K calls)": "价格($/1K 次)",
     "Price (local currency / USD)": "价格(本地货币/美元)",
@@ -2757,6 +2776,7 @@
     "Ratio Type": "比率类型",
     "Ratio: {{value}}": "倍率:{{value}}",
     "Ratios synced successfully": "比率同步成功",
+    "Raw expression": "原始表达式",
     "Raw JSON": "原始 JSON",
     "Raw Quota": "原生额度",
     "Re-enable on success": "成功后重新启用",
@@ -2801,6 +2821,7 @@
     "Reference Video": "参照生视频",
     "Referral link:": "推荐链接:",
     "Referral Program": "推荐计划",
+    "Refine models by provider, group, type, and tags.": "按供应商、分组、类型和标签细化模型。",
     "Refresh": "刷新",
     "Refresh Cache": "刷新缓存",
     "Refresh credential": "刷新凭据",
@@ -2990,7 +3011,6 @@
     "Save drawing settings": "保存绘图设置",
     "Save Epay settings": "保存 Epay 设置",
     "Save failed": "保存失败",
-    "Save Preferences": "保存偏好设置",
     "Save failed, please retry": "保存失败,请重试",
     "Save general settings": "保存通用设置",
     "Save group ratios": "保存分组比率",
@@ -3001,6 +3021,7 @@
     "Save monitoring rules": "保存监控规则",
     "Save navigation": "保存导航",
     "Save notice": "保存通知",
+    "Save Preferences": "保存偏好设置",
     "Save rate limits": "保存速率限制",
     "Save sensitive words": "保存敏感词",
     "Save Settings": "保存设置",
@@ -3021,6 +3042,7 @@
     "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "使用您的身份验证器应用(Google Authenticator、Microsoft Authenticator 等)扫描此二维码",
     "Scenario Templates": "场景模板",
     "Scheduled channel tests": "定期渠道测试",
+    "scheduling controls": "调度控制能力",
     "Scope": "作用域",
     "Scopes": "作用域",
     "Search": "搜索",
@@ -3035,6 +3057,7 @@
     "Search group names...": "搜索分组名称...",
     "Search groups...": "搜索分组...",
     "Search missing models": "搜索缺失的模型",
+    "Search model name, provider, endpoint, or tag...": "搜索模型名称、供应商、端点或标签...",
     "Search model name...": "搜索模型名称...",
     "Search models": "搜索模型",
     "Search models or fields...": "搜索模型或字段...",
@@ -3219,6 +3242,7 @@
     "sources": "来源",
     "Space-separated OAuth scopes": "以空格分隔的OAuth作用域",
     "Spark model version, e.g., v2.1 (version number in API URL)": "Spark 模型版本,例如 v2.1(API URL 中的版本号)",
+    "Special billing expression": "特殊计费表达式",
     "Special usable group rules": "特殊可用分组规则",
     "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 将所有数据存储在单个文件中。在容器中运行时请确保该文件已持久化。",
     "SSRF Protection": "SSRF 保护",
@@ -3425,6 +3449,7 @@
     "This page has not been created yet.": "此页面尚未创建。",
     "This project must be used in compliance with the": "此项目的使用必须遵守",
     "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器 IP、回调 IP、支付方式与系统版本等审计字段。",
+    "This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
     "this token group": "此令牌分组",
     "this user group": "此用户分组",
     "This user has no bindings": "该用户无任何绑定",
@@ -3563,6 +3588,7 @@
     "Unable to generate chat link. Please contact your administrator.": "无法生成聊天链接。请联系您的管理员。",
     "Unable to load groups": "无法加载分组",
     "Unable to open chat": "无法打开聊天",
+    "Unable to parse structured pricing": "无法解析为结构化价格",
     "Unable to prepare chat link. Please ensure you have an enabled API key.": "无法准备聊天链接。请确保您有一个已启用的 API 密钥。",
     "Unauthorized": "未授权",
     "Unauthorized Access": "未经授权的访问",
@@ -3629,6 +3655,7 @@
     "Upstream prices fetched successfully": "已成功获取上游价格",
     "Upstream ratios fetched successfully": "上游比率获取成功",
     "Upstream Response": "上游返回",
+    "upstream services integrated": "上游服务适配",
     "Upstream Updates": "上游更新",
     "uptime": "正常运行时间",
     "Uptime": "运行时间",

+ 1 - 0
web/default/src/routes/pricing/$modelId/index.tsx

@@ -11,6 +11,7 @@ const modelDetailsSearchSchema = z.object({
   endpointType: z.string().optional(),
   tag: z.string().optional(),
   tokenUnit: z.enum(['M', 'K']).optional(),
+  view: z.enum(['card', 'table']).optional().catch(undefined),
   rechargePrice: z.boolean().optional(),
 })
 

+ 1 - 1
web/default/src/routes/pricing/index.tsx

@@ -11,7 +11,7 @@ const pricingSearchSchema = z.object({
   endpointType: z.string().optional(),
   tag: z.string().optional(),
   tokenUnit: z.enum(['M', 'K']).optional(),
-  view: z.enum(['list', 'table']).optional(),
+  view: z.enum(['card', 'table']).optional().catch(undefined),
   rechargePrice: z.boolean().optional(),
 })