🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)

* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace

* fix: CrossGroupRetry default false

移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false
且bool默认false不影响原有功能

* feat: check-in feature integrates Turnstile security check

* feat: add support for Doubao /v1/responses (#2567)

* feat: add support for Doubao /v1/responses

* fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556)

* fix: fix model deployment style issues, lint problems, and i18n gaps.

* fix: adjust the key not to be displayed on the frontend, tested via the backend.

* fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined.

* feat: add plans directory to .gitignore

* fix: 修复 gemini 文件类型不支持 image/jpg

* fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout.

* fix: batch add key backend deduplication

* Merge pull request #2582 from seefs001/fix/tips

fix: add tips for model management and channel testing

* fix(gin): update request body size check to allow zero limit

* feat: add regex pattern to mask API keys in sensitive information

* fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题

问题描述:
- 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建,
  但使用日志不显示记录,且不会扣费

根本原因:
- Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中
- 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup
- 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆")
- 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0
- 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费

修复方案:
- 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup
  获取实际分组的逻辑
- 使用安全的类型断言,避免潜在的 panic 风险

影响范围:
- 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口)
- 不影响使用具体分组令牌的调用
- 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑)

* 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs

Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system.

- **Model deployments (io.net)**
  - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`)
  - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration
  - Improve create-deployment flow (proper request shape, name availability check, price estimation parity)

- **System settings**
  - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance

- **Channels / Ollama**
  - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation
  - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib`

- **Quality**
  - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration

* Merge pull request #2590 from xyfacai/fix/max-body-limit

fix: 设置默认max req body 为128MB

* docs: update readme

* i18n: add missing translations

* fix(gemini): fetch model list via native v1beta/models endpoint

Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible
path when listing models for Gemini channels, improving compatibility with
third-party Gemini-format providers that don't implement OpenAI routes.

- Add paginated model listing with timeout and optional proxy support
- Select an enabled key for multi-key Gemini channels

* refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型

* fix: remove Minimax from FETCHABLE channels

* fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList

* feat: add doubao video 1.5

* 🤢 chore: remove useless file

* feat: /v1/chat/completion -> /v1/response (#2629)

* feat: /v1/chat/completion -> /v1/response

* fix: clean propertyNames for gemini function

* fix: support snake_case fields in GeminiChatGenerationConfig

* chore: update dependencies and lockfile for improved compatibility

- Updated @clerk/clerk-react to version 5.59.3
- Updated @hookform/resolvers to version 5.2.2
- Updated @lobehub/icons to version 2.48.0
- Updated various Radix UI components to their latest versions
- Updated @tanstack/react-query and related packages for better performance
- Updated axios, i18next, and other libraries for security and feature enhancements
- Updated lockfile to include configVersion and ensure consistency across environments

* Merge pull request #2647 from seefs001/feature/status-code-auto-disable

feat: status code auto-disable configuration

* fix: chat2response setting ui (#2643)

* fix: setting ui

* fix: rm global.chat_completions_to_responses_policy

* fix: rm global.chat_completions_to_responses_policy

* Merge pull request #2627 from seefs001/feature/channel-test-param-override

feat: channel testing supports parameter overriding

* chore: update dependencies and lockfile for improved compatibility

- Updated @lobehub/icons to version 4.0.3
- Updated ai to version 6.0.27
- Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements
- Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility
- Updated lockfile to ensure consistency across environments

* chore: update lockfile and Vite configuration for improved build process

- Updated lockfile to version 1 for better compatibility and consistency
- Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies
- Added environment-specific console and debugger removal for production builds

* chore: migrate from Vite to Rsbuild for build process

- Added Rsbuild configuration for development and production builds
- Updated package.json scripts to use Rsbuild instead of Vite
- Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies
- Introduced postcss.config.mjs for Tailwind CSS integration
- Updated TypeScript configuration to include Rsbuild config
- Removed Vite configuration file to streamline the build process

* refactor: optimize user data handling and API calls

- Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components.
- Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests.
- Added caching for setup status checks to enhance performance during navigation.

* feat: enhance session validation in authenticated route

- Implemented session verification to check user authentication status via API call only once per session.
- Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage.
- Improved user data handling by updating the auth store with fresh user information upon successful session verification.

* refactor: improve useMediaQuery hook for better SSR handling

- Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering.
- Simplified state initialization and change handling by using a dedicated function to determine initial matches.
- Updated event listener management for improved performance and clarity.

* feat(hooks): export useMediaQuery from hooks index

* refactor: update useMediaQuery imports to use unified hooks index

* fix(rsbuild): fix loadEnv API usage and removeConsole type

* feat: customizable automatic retry status codes

* refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery

* refactor: simplify embedded file structure in main.go

- Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process.

* refactor: replace DropdownMenu with Sheet component in ProfileDropdown

- Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu.
- Enhanced user info display with improved layout and styling.
- Added navigation links and sign-out functionality within the Sheet.

* refactor: streamline ProfileDropdown layout and improve user info display

- Removed unused Badge component and secondary text from user display.
- Enhanced styling for user info section and navigation links.
- Updated sign-out functionality to use a button for better accessibility.

* feat: add System Settings link for super admin in ProfileDropdown

- Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role.
- Updated imports to include the Settings icon and adjusted the component logic accordingly.
- Removed the Settings entry from the sidebar data to streamline navigation.

* feat: codex channel (#2652)

* feat: codex channel

* feat: codex channel

* feat: codex oauth flow

* feat: codex refresh cred

* feat: codex usage

* fix: codex err message detail

* fix: codex setting ui

* feat: codex refresh cred task

* fix: import err

* fix: codex store must be false

* fix: chat -> responses tool call

* fix: chat -> responses tool call

* feat(i18n): add missing translations

* fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales

- Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales.
- Added the "User Menu" translation in the same languages to enhance user interface consistency.

* fix: openAI function to gemini function field adjusted to whitelist mode

* feat: TLS_INSECURE_SKIP_VERIFY env

* fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently.

* refactor(system-settings): restructure settings sections and navigation

- Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency.
- Introduced a section registry to manage general settings sections dynamically.
- Updated navigation items in the system settings sidebar to utilize the new section registry.
- Enhanced the GeneralSettings component to support section-based content rendering based on user selection.

* fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings

- Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code.

* refactor(system-settings): update zod import syntax in general settings

- Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase.

* fix: the login method cannot be displayed under the aff link.

* feat(system-settings): implement generic settings page and enhance navigation

- Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering.
- Integrated section registry for general and authentication settings to streamline navigation and content management.
- Updated URL utility functions to improve query parameter handling for active navigation states.
- Enhanced the system settings sidebar to include authentication section items dynamically.

* refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings

- Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency.
- Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering.

* feat(system-settings): enhance request limits settings with new section and unified component

- Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency.
- Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering.
- Implemented a search schema for request limits to streamline navigation and section management.

* feat(system-settings): integrate content settings sections with unified component and registry

- Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation.
- Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency.
- Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience.
- Updated the ContentSettings component to utilize the new section registry and streamline content display.

* feat(system-settings): enhance integrations settings with unified section registry and components

- Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency.
- Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering.
- Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility.

* feat(system-settings): unify model settings with new section registry and components

- Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency.
- Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering.
- Added a new Models section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): enhance maintenance settings with unified section registry and components

- Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation.
- Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency.
- Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering.
- Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility.

* feat(system-settings): update section titles for improved clarity and consistency

- Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities.
- Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding.
- Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation.

* feat(nav-group): enhance collapsible menu behavior and URL matching logic

- Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths.
- Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection.
- Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation.

* feat(system-settings): update system settings navigation and redirect logic

- Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification.
- Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency.

* feat(system-settings): unify route configuration for settings sections

- Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function.
- This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability.
- Enhanced navigation by ensuring default sections are loaded when no section parameter is provided.

* feat(url-utils): enhance URL handling and matching logic

- Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format.
- Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters.
- Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection.

* feat(system-settings): validate DataExportDefaultTime for improved data handling

- Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values.
- Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience.

* perf(system-settings): Improve the i18n of system settings content

- Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience.
- Updated validation messages in schemas to utilize translation functions for improved internationalization support.
- Ensured all user-facing strings are properly translated, improving accessibility for non-English users.

* fix(system-settings): update ApiInfoFormValues type inference for improved schema validation

- Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section.

* fix(chat-settings): improve validation logic for chat settings schema

- Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema.
- Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings.

* fix(system-settings): enhance validation error handling in uptime-kuma schema

- Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback.
- Ensured that all validation messages are properly structured to enhance internationalization support.

* fix(i18n): add translations for Uptime Kuma group management

- Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility.

* fix(system-settings): improve validation error message structure for SystemName

- Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback.
- This change aligns with recent improvements in internationalization support across the system settings schemas.

* perf(i18n): add new validation error message translations

- Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances internationalization support by ensuring users receive clear feedback across multiple languages.

* fix(i18n): update Japanese translation for payment method configuration message

- Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback.
- This change enhances the clarity of the message for Japanese-speaking users.

* fix(i18n): remove unnecessary loading messages from French translations

- Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file.
- This change improves the clarity and relevance of the translations provided to users.

* fix(i18n): add translations for Uptime Kuma group management in multiple languages

- Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support.
- This update improves user experience by providing clear and consistent messaging across different languages.

* fix(validation): enhance pricing schema error messages and add translations

- Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid.
- Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support.
- This change enhances user experience by providing accurate and contextually relevant messages across multiple languages.

* fix: codex Unsupported parameter: max_output_tokens

* fix(model-mapping-editor): simplify JSON parsing logic in useEffect

* fix: jimeng i2v support multi image by metadata

* refactor(models): restructure models section handling and improve UI components

- Replaced tab-based navigation with section-based navigation for better clarity and organization.
- Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'.
- Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button.
- Removed the ModelsTabs component as it was no longer needed.
- Enhanced internationalization support by adding new translations for section descriptions and management tasks.
- Adjusted sidebar configuration to accommodate the new section structure.

* fix: update warning threshold label from '5$' to '2$'

* fix: video content api Priority use url field

* fix: update abortWithOpenAiMessage function to use types.ErrorCode

* feat(deployment): introduce CreateDeploymentDrawer component and update dialog references

- Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience.
- Added comprehensive form handling for deployment creation, including validation and price estimation features.
- Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration.
- Enhanced the ModelsContent component to integrate the new drawer for creating deployments.

* perf(i18n): enhance internationalization for models table and columns

- Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization.
- Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers.
- Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users.

* fix: fix email send

* fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0

* fix: replace Alibaba's Claude-compatible interface with the new interface

* fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion.

* feat: log shows request conversion

* feat: optimized display

* feat: optimized display

* feat: optimized display

* fix: codex rm Temperature

* Revert "fix: video content api Priority use url field"

* feat: requestId time string use UTC

* feat(qwen): support qwen image sync image model config

* feat: sync old ui

* feat: more ui sync

* feat: replace theme

* fix build

* refactor(web): revert theme colors and variables in CSS

Updated color variables for light and dark themes to improve consistency and visual appeal.

* feat(deployment): enhance deployment access guard and model deployment settings

- Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks.
- Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness.
- Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls.
- Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing.

* feat(i18n): add new translations for connection and loading states across multiple languages

- Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases.

* refactor(pagination): adjust layout and styling for pagination component

- Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness.
- Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes.

* feat(i18n): implement translations for various UI elements across multiple components

- Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support.
- Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese.
- This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components.

* feat(i18n): integrate translation support in SummaryCards component

- Added the useTranslation hook to the SummaryCards component to enhance internationalization.
- This update allows for localized text rendering, improving user experience for diverse language speakers.

* feat(dashboard): refactor dashboard structure and introduce section-based navigation

- Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content.
- Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance.
- Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed.
- Added translations for new section titles and descriptions to support internationalization.

* feat(i18n): update time range labels and enhance translation support

- Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity.
- Updated various components to utilize the translation function for time range labels, improving internationalization.
- Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages.

* feat(dashboard): enhance type safety and improve component structure

- Updated the Dashboard component to use specific types for model data and filters, enhancing type safety.
- Introduced new types for announcements and FAQs, improving clarity and maintainability.
- Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management.
- Optimized the rendering of announcements and FAQs by using unique keys based on item IDs.
- Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports.

* feat(agents): add comprehensive guidelines for React and Next.js development

- Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability.
- Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management.
- Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices.
- This update serves as a foundational resource for improving code quality and efficiency in React-based projects.

* chore(web): update package.json dependencies

- Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project.
- Updated remaining dependencies to their latest versions for improved performance and security.
- This cleanup enhances the overall maintainability of the project.

* feat(usage-logs): implement section-based navigation and enhance log management

- Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task).
- Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section.
- Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity.
- Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types.
- Updated routing to redirect to the default section if none is specified, improving user experience.

* feat(i18n): enhance internationalization across usage logs components

- Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers.
- Updated labels, titles, and messages to utilize translation functions, improving localization support.
- Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers.

* feat(datetime-picker): integrate dayjs for date formatting

- Added dayjs as a dependency to the project for improved date handling.
- Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays.

* feat(date-handling): replace date-fns with dayjs for improved date management

- Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components.
- Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities.
- Added a new dayjs configuration file to extend its capabilities with relative time support.
- Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling.

* refactor(agents): streamline front-end development guidelines and update technology stack

- Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants.
- Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries.
- Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections.

* feat(i18n): enhance date picker and datetime picker localization support

- Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese).
- Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels.
- Improved user experience by allowing date selection to adapt based on the user's language preference.

* feat(layout): add SectionPageLayout component for structured page layouts

- Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content.
- Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures.
- Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability.

* feat(layout): refactor components to use SectionPageLayout for improved structure

- Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet.
- Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability.
- This update standardizes the layout structure across the application, facilitating a more cohesive user experience.

* feat(usage-logs): enhance URL state management and redirection logic

- Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references.
- Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness.

* fix(usage-logs): disable global filter and update DataTableToolbar props

- Disabled the global filter in the UsageLogsTable component to streamline the user interface.
- Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration.

* feat(routes): implement section-based routing for system settings and dashboard

- Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization.
- Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components.
- Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience.
- Added new section routes for models, usage logs, and system settings, ensuring consistency across the application.
- Removed deprecated routes to streamline the routing structure and improve maintainability.

* refactor(usage-logs): update column helper functions to require config parameter

- Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional.
- Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability.

* refactor(usage-logs): improve section ID validation and routing logic

- Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting.
- Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability.
- Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary.

* refactor(calendar): update locale documentation for i18n support

- Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users.

* chore(i18n): remove redundant user information description from locale files

- Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity.

* chore(i18n): streamline locale files by removing redundant entries

- Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter.
- Adjusted translations for consistency and improved user experience across multiple languages.

* chore(sidebar): remove deprecated usage logs route from sidebar config

- Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure.

* refactor(redemption-codes): enhance internationalization support and improve UI consistency

- Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Added meta labels for table columns to improve accessibility and clarity.
- Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience.
- Updated locale files to include new translations for improved clarity and consistency.

* feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling

- Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access.
- Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency.
- Revised translation keys in locale files to remove colons for improved clarity.

* refactor(url-utils): simplify query parameter matching logic in checkIsActive function

- Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters.
- Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code.

* fix(channels-table): update group filter label to use translation function

- Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component.

* chore(api-keys): remove deprecated API key action messages and related exports

- Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys.
- Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality.

* refactor(i18n): enhance internationalization support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity.
- Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility.
- Streamlined the handling of i18n keys in the constants files for better organization and clarity.

* refactor(i18n): enhance translation support across various components

- Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages.
- Revised pagination and status labels to use i18n keys, improving maintainability and clarity.
- Enhanced response time formatting to support internationalization, allowing for localized display of time values.
- Updated locale files to include new translations for improved clarity and consistency.

* docs(AGENTS): add type checking requirement for TypeScript changes

- Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved.
- Updated the document to reflect this addition in the relevant section for better clarity on coding standards.

* feat(combobox-input): add ComboboxInput component for enhanced token selection

- Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities.
- Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name.
- Updated locale files to include new translations for user-facing strings related to token filtering.

* feat(combobox): integrate translation support for custom value prompt

- Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt.
- Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages.

* refactor(i18n): improve Chinese translations for consistency and clarity

- Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file.
- Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards.

* feat(calendar): add CalendarDropdown component for enhanced dropdown functionality

- Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar.
- Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience.
- Updated styling for dropdown elements to ensure consistency and better alignment with the UI design.

* fix(balance-query-dialog): handle null currentRow and improve usage query logic

- Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining.
- Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors.
- Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity.

* feat(i18n): add new translations for batch creation and channel updates

- Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance.
- Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings.
- Ensured consistency in terminology across locale files for better user experience.

* feat(channel-mutate-drawer): improve API key input handling and update translations

- Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability.
- Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance.
- Ensured consistency in translation strings across locale files for better user experience.

* feat(fetch-models-dialog): implement sorting for model categories

- Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation.
- Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience.

* refactor(wallet-stats-card): standardize props usage and improve layout consistency

Standardizes props usage and improves layout consistency in wallet stats card

Refactors the wallet stats card component to:
- Use props directly instead of destructuring for consistency
- Add min-w-0 to prevent content overflow
- Adjust text sizing with break-all for proper wrapping
- Implement responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improve leading and tracking for better readability

Refactor wallet stats card for consistency and layout

Standardizes props usage and improves layout consistency in wallet stats card

- Uses props directly instead of destructuring for consistency
- Adds min-w-0 to prevent content overflow
- Adjusts text sizing with break-all for proper wrapping
- Implements responsive font sizes (3xl on mobile, 4xl on larger screens)
- Improves leading and tracking for better readability

* feat(web): add subscription management and admin settings UI

* feat(web): add subscription management and admin settings UI

- Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes)
- Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry
- Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile
- Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks

Made-with: Cursor

* fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate

- remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version.
- add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure.

* feat(router): add chat2link and subscriptions routes

- register /chat2link page route under authenticated layout.
- register /subscriptions/ page route under authenticated layout.
- update auto-generated routeTree type definitions and route mappings.

* feat(docker): add development environment setup with Docker Compose

- Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL.
- Created Dockerfile.dev for backend-only builds, optimizing the development workflow.
- Updated makefile to include new commands for starting backend services and frontend development.

* feat(web): complete i18n coverage for setup wizard and add language switcher

- wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings.
- add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup.
- register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi.

* feat(i18n): internationalize default version text in workspace-switcher

- remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback
- add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi)

* feat(i18n): add full i18n coverage for channel-affinity settings page

- replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions.
- add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog.
- register section-registry dynamic keys in static-keys.ts.

* feat(i18n): add full i18n coverage for Waffo payment settings page

- replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency.
- wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t().
- add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi).

* feat(i18n): add missing translations for global model settings page

- add all 6 locale translations for 3 missing t() keys in global-settings-card.
- register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage.

* feat(i18n): add full i18n coverage for Grok model settings page

- add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls.
- cover violation fee toggle, amount input, and official docs link labels.
- include section-registry descriptionKey translation entries.

* feat(i18n): add full i18n coverage for performance settings page

- migrate all t() keys from Chinese to English to align with project conventions.
- add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache,
  system monitoring, log management, and stats dashboard sections.
- remove 71 obsolete Chinese-keyed entries from every locale file.

* fix(i18n): add 116 missing English translation keys across all locales

- scan all t() calls to identify English keys used in code but absent from locale files.
- add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent.
- covers system-settings, channels, models, auth, wallet and other modules.

* fix(i18n): add missing translations for log cleanup quick-select and confirm dialog

- wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t().
- replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation.
- add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi).

* refactor(web): unify all time display with dayjs formatting

- replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format().
- standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss.
- add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts.
- update 12 files across core utils and feature components.

* refactor(web): replace native datetime-local input with DateTimePicker in announcements

- swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages.
- convert between Date objects and ISO strings to bridge the form's string-based schema.

* refactor(web): replace native HTML elements with design system components

- replace ~35 native <button> with <Button> across pricing, profile, channels modules
- replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling
- replace native <table> with <Table> components, <details>/<summary> with <Collapsible>
- replace decorative <hr> with <Separator> to ensure global UI consistency

* refactor(web): enhance profile components with design system consistency

- update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling.
- modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment.
- refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity.

* fix(i18n): complete i18n coverage for profile page components

- wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t()
- fix hardcoded button labels in security dialogs (change password, access token, delete account)
- internationalize all 2FA dialog strings (setup, disable, backup codes)
- fix email bind dialog description and button state text missing i18n
- wrap remaining hardcoded strings in notification tab and checkin calendar
- add all missing translation entries to zh.json and en.json

* fix(i18n): enhance error messages with translations for deployment access and settings

- wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization.
- add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh).

* 🧹 chore(web): resolve all ESLint errors and warnings

Align the Vite/React frontend with the current ESLint flat config and
React Compiler–related rules by fixing violations instead of broad
suppression where practical.

- Replace `any` with concrete types (`unknown`, `Record<string, unknown>`,
  domain types) where upstream/API shapes allow
- Fix duplicate imports, unused bindings, `no-console`, and empty blocks
- Address react-hooks issues: reorder declarations, memoize unstable
  callbacks (`useCallback`), extend dependency arrays, and use targeted
  disables only where sync-from-props in `useEffect` is intentional
- Refactor `motion.create` usage in ai-elements shimmer to avoid creating
  components during render (static-components)
- Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in
  deps) and add narrowly scoped rule disables where the linter conflicts
  with library patterns
- Disable `react-hooks/incompatible-library` in ESLint config for
  TanStack Table / RHF false positives
- Add file-level `react-refresh/only-export-components` disables for
  registry/provider/column modules that intentionally mix exports

`bun lint` completes with 0 errors and 0 warnings.

*  feat(web): add subscription management to sidebar and align drawer with project conventions

- Register "Subscription Management" nav item in the admin sidebar group
  with CreditCard icon pointing to /subscriptions
- Add subscription module to sidebar config defaults and URL mapping so it
  integrates with the admin sidebar modules toggle in system settings
- Add subscription entry to sidebar-modules-section moduleMeta for the
  maintenance settings UI
- Refactor SubscriptionsMutateDrawer to follow the same patterns used by
  users, redemption-codes, and other mutate drawers:
  - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage
    instead of raw register() + Label + manual error display
  - Move SheetFooter outside the form with form attribute association
  - Use SheetClose for the cancel button
  - Reset form state on drawer close
  - Align SheetContent width (sm:max-w-[600px]) and spacing conventions

*  feat(web): overhaul UI/UX with Vercel Geist design alignment

Refactor the entire frontend UI/UX to align with Vercel/OpenAI design
principles, covering layout, animations, skeleton loading, and overall
visual polish.

Motion & Page Transitions:
- Add centralized motion system (lib/motion.ts) with Vercel-style
  transition presets, stagger variants for tables, cards, and sidebars
- Implement AnimatedOutlet for route-level page enter animations
  using TanStack Router pathname keying
- Add PageTransition, StaggerContainer, StaggerItem, CardStagger,
  and TableStagger wrapper components for progressive reveal effects

Skeleton Loading — Vercel Geist Style:
- Replace shadcn default `animate-pulse` with Geist-style shimmer
  sweep animation (linear-gradient + background-position keyframes)
- Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned
  for both light and dark themes with neutral oklch tones
- Override auto-skeleton-react inline styles via CSS to unify all
  skeleton elements under the same shimmer effect
- Update TableSkeleton with varied column widths for a natural feel
- Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton
  integration with React Query error/loading states
- Respect prefers-reduced-motion: disable shimmer for accessibility

Layout & Sidebar:
- Upgrade sidebar expand/collapse transitions to cubic-bezier easing
- Add hover micro-interactions (background-color, color, transform)
  to sidebar menu buttons with smooth 150ms transitions
- Fix oklch color compatibility in sidebar outline variant
- Integrate AnimatedOutlet into AuthenticatedLayout for unified
  route-level animations

Theme & CSS:
- Streamline theme.css with cleaner oklch color definitions
- Add CSS table row stagger-in animations with nth-child delays
- Fix hover-scrollbar color bug (hsl → color-mix for oklch compat)
- Add content-auto utility for long list rendering optimization

Cleanup:
- Remove deprecated skeleton-wrapper.tsx
- Remove unused imports and dead code across components
- Add empty-state, error-state, and loading-state utility components

* 🐛 fix(docker): track bun.lock to fix Docker build failure

Remove `web/bun.lock` from `.gitignore` so the lock file is committed
to version control. The Dockerfile `COPY web/bun.lock .` instruction
requires this file to be present in the build context, and ignoring it
caused the build to fail with a "not found" error.

* ⬆️ chore(web): upgrade dependencies and fix all type/lint errors

Upgrade all frontend dependencies to latest stable versions:
- lucide-react 0.562 → 1.7 (major: brand icons removed)
- shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x
- @rsbuild/core 1.3 → 1.7, @types/node 24 → 25
- tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38
- @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6
- react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6
- prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57
- Add missing optional deps: @xyflow/react, embla-carousel-react

Resolve all TypeScript compilation errors introduced by upgrades:
- Replace lucide-react brand icons (Github) with react-icons/si
- Fix react-hook-form Control/Resolver generics for zod v4
- Fix Record<string, unknown> type constraints across API utils
- Fix axios interceptor return types in lib/api.ts
- Add type assertions for useSettings/useStatus hook returns
- Resolve Badge variant, spread type, and route path mismatches

Resolve all ESLint 10 errors:
- preserve-caught-error: attach cause to re-thrown errors
- no-useless-assignment: refactor redundant variable assignments
- prefer-as-const: use `as const` over literal type assertions
- no-unused-vars: prefix type-only schemas with underscore

Update tsconfig lib from ES2020 to ES2022 for Error.cause support.

* 🐛 fix(web): stop pricing model row from centering its content

Wrapping the row in shadcn <Button variant='ghost'> inherits
`justify-center`, and the inner flex container had no width, so
`justify-between` collapsed and the row appeared centered.

* feat: add Waffo payment integration and related UI components

- Introduced Waffo payment method with support for custom icons and settings.
- Updated payment settings section to include Waffo settings.
- Added Waffo payment request handling in the wallet API.
- Enhanced wallet recharge form to support Waffo payment methods.
- Implemented hooks for Waffo payment processing.
- Updated localization files for new Waffo-related strings.
- Added new payment type and icon for Waffo in constants and UI components.
- Refactored topup info handling to include Waffo payment methods and configurations.

* feat(profile): add admin-only upstream model update notification setting

* fix(web): make sidebar module user settings actually take effect

Previously, saving sidebar module preferences in profile had no effect
because the client ignored user-level sidebar_modules entirely. This
fix wires user config into useSidebarConfig so the sidebar updates
immediately without a page refresh.

Changes:
- Add UserPermissions type with sidebar_settings/sidebar_modules fields
- Refactor useSidebarConfig to merge admin × user config with AND logic
- Sync sidebar_modules to auth store on save for immediate UI updates
- Conditionally render SidebarModulesCard based on user permissions
- Treat null/empty user config as "do not narrow" for legacy users

* feat(web): add custom OAuth provider CRUD and login button support

Migrate custom OAuth from v1 to v2:
- Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery
- Login page renders dynamic buttons for custom OAuth providers
- Fix account bindings display showing "Not bound" text when already bound

* feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings

Migrate missing v1 system settings to v2:
- ServerAddress input in General > System Information
- SMTPForceAuthLogin toggle in Integrations > Email
- CreateCacheRatio JSON editor in Models > Ratio
- Group special usable group rules editor in Models > Ratio

* feat(web): wire user subscriptions dialog to users table row actions

The UserSubscriptionsDialog component already existed but had no entry point
in the users table dropdown menu. Add "Manage Subscriptions" menu item.

* chore(web): update i18n translations for new settings and custom OAuth

* 💎 refactor(web): redesign pricing page with flat, typography-driven layout

* 🌐 chore(i18n): complete missing translations and normalize project config

- Add 425+ missing translations across fr, ja, ru, zh, vi locales
  for subscription management, sidebar navigation, Grok settings,
  upstream model updates, pricing page, and other UI components
- Add 37 missing i18n keys used in t() calls but absent from locale
  files (pricing filters, display options, audio/cache labels, etc.)
- Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc:
  React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind
- Fix i18n key format description: "Chinese source strings" → English
- Deduplicate .cursor/rules/project.mdc to avoid triple-loading the
  same rules already present in root CLAUDE.md and AGENTS.md
- Add i18n-translate Cursor skill for repeatable translation workflow

* 🎨 refactor(web): redesign dashboard with flat, typography-driven layout

Replace Card-based dashboard components with a flat, border-driven design
system consistent with the pricing page, following the ui-style.mdc conventions.

Overview section:
- StatCard: replace Card wrapper with flat flex layout, monospace tabular
  values, uppercase tracking-wider labels, layered opacity hierarchy
- PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border
  container and border-b header
- SummaryCards: merge three stat cards into a single bordered container
  with divide-x separators; decouple border from stagger animation to
  prevent border deformation during entrance transitions
- ApiInfoPanel/Item: full-width list rows with border-b separators,
  monospace route names, layered opacity for URLs and descriptions
- AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for
  "Click for details" hint
- FAQPanel: lighter border-border/60 accordion dividers, muted answer text
- UptimePanel: uppercase tracking-wider group headers with bg-muted/30
  background, monospace uptime percentages, fine-grained border opacity

Models section:
- LogStatCards: replace Card with rounded-lg border + divide-x grid,
  fix react-hooks/exhaustive-deps by destructuring props before useEffect
- ModelCharts: replace Card+Tabs with bordered container + custom
  segmented control matching ui-style spec
- Suspense fallbacks: match new flat skeleton layout with accurate
  column structure

Animation:
- Wrap models section in FadeIn with staggered delay
- Keep CardStagger for overview panel grid (each panel has own border)

Other:
- Add ui-style.mdc cursor rule documenting the design language
- Disable react-refresh/only-export-components for src/routes/** in
  eslint config (TanStack Router route files always export Route objects)
- Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费"

*  refactor(web): adopt flat dot-and-text design for all status badges

Replace the bordered/colored-background StatusBadge and Badge components
across the entire frontend with a minimal flat design: a small colored
dot followed by colored text, eliminating visual noise from heavy
borders, backgrounds, and rounded pill shapes.

Key changes:

- Redesign StatusBadge to use dot + text instead of bordered pill style,
  removing cva-based background/border variants in favor of exported
  dotColorMap and textColorMap lookup tables
- Add children prop support to StatusBadge for flexible content rendering
  alongside the existing label prop
- Migrate all Badge usages (except pricing page) to StatusBadge with
  appropriate variant mappings (default→info, secondary→neutral,
  outline→neutral, destructive→danger)
- Consolidate adjacent multi-badge groups into single-dot layouts with
  dot separators (·) to reduce visual clutter in:
  - Channel balance columns (used + remaining)
  - Channel type column (type + IO.NET indicator)
  - User invite info column (invited + revenue + inviter)
  - Usage log stats bar (usage + RPM + TPM)
  - Usage log time/FRT column (time + FRT + stream status)
  - Subscription plan counts (active + expired)
  - Channel affinity scope/regex/key-source columns
  - Prefill group card headers (type + ID)
- Export dotColorMap and textColorMap for direct use in custom inline
  layouts that need consistent status colors without the full component

*  refactor(web): redesign public layout and landing page with modern UI

Overhaul the public-facing layout, header, and homepage to deliver a
polished, animation-rich landing experience inspired by contemporary
SaaS design patterns.

Header:
- Replace sticky header with fixed floating navbar that compacts into
  a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring)
- Add smooth 700ms cubic-bezier transitions for scroll-based shrinking
- Build full-screen mobile menu overlay with staggered entry animations
- Remove background color from logo container, show logo image directly

Homepage sections:
- Hero: gradient text title, radial gradient + grid pattern background,
  interactive terminal demo showcasing API request/response
- Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514,
  gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions,
  clickable model badges, dual theme support (light/dark), fixed height
- Stats: animated counters driven by IntersectionObserver with
  cubic-bezier easing, supports integer and decimal modes
- Features: Bento grid layout with gap-px border technique, each card
  includes contextual visuals (model list, security badge, workflow)
- How It Works: new three-step process section (Configure → Connect →
  Monitor) with connecting gradient line and numbered badges
- CTA: gradient mesh background with scale-in scroll animation
- Footer: streamlined brand column + link columns layout

New components:
- AnimateInView: IntersectionObserver-based scroll animation component
  supporting fade-up, fade-in, scale-in, fade-left, fade-right
- HeroTerminalDemo: themed terminal with model carousel and live
  request/response preview

CSS:
- Add landing page scroll-triggered keyframe animations
- Add terminal demo animations (blink cursor, spinner, pulse indicator)
- Respect prefers-reduced-motion throughout

i18n:
- Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi)

*  feat(web): align usage logs and channels with legacy UI

Usage logs
- Show Refund (type 6) in detail dialog and hide conversion chain for refunds
- Sync filter dialog state from URL for model, token, group, username, and requestId

Channels
- Support optional stream flag in channel test API, actions, and test dialog
- Show upstream model update badges (+added / -removed) on fetchable channel types
- Add form fields and drawer toggles for upstream model update check and auto-sync
- Persist upstream model update flags in channel settings JSON for fetchable types

i18n
- Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi)

* 🐛 fix(web): prevent transient vertical scrollbar on tables during animations

Add overflow-y-clip to the shared Table container (data-slot=table-container)
alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with
overflow-y: auto in browsers, which made the table shell briefly show a
vertical scrollbar during route enter motion (y/blur) and table row stagger.

Remove the redundant descendant selector workaround from the model pricing
GroupPricingSection; behavior is now covered globally by the Table component.

* 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer

Overhaul the authenticated console layout to match the OpenAI dashboard
pattern: header and page title bar stay fixed at the top, only the
content area scrolls, and table pagination is pinned to the bottom.

Layout architecture:
- Lock SidebarInset to full viewport height (h-svh) so all inner
  regions are controlled by flexbox instead of document scroll
- Convert Main from a generic div to a semantic <main> flex container
  with overflow-hidden, removing the legacy `fixed` prop and
  `data-layout` attribute
- Strip scroll-shadow logic and `fixed` prop from Header/AppHeader;
  the header is now naturally fixed as a shrink-0 flex child
- Restructure SectionPageLayout into three flex regions: a shrink-0
  title bar, a flex-1 overflow-auto content area, and a shrink-0
  footer portal target with empty:hidden
- Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow

Footer portal system:
- Introduce PageFooterProvider / PageFooterPortal (React Context +
  createPortal) so deeply nested table components can render their
  DataTablePagination into the fixed footer without prop drilling
- Migrate all 8 data tables (api-keys, channels, users, models,
  deployments, usage-logs, subscriptions, redemption-codes) to use
  PageFooterPortal for pagination

Page-level fixes:
- Profile: wrap content in a scrollable flex child with proper padding
- SystemSettings: remove overflow-auto from wrapper to avoid nested
  scrollbars (sub-pages manage their own scroll)
- Playground / Error pages: remove obsolete `fixed` props

API keys UX improvement:
- Replace inline key show/hide toggle with a Popover-based reveal,
  removing toggleKeyVisibility and keyVisibility state from the
  provider context

Cleanup:
- Remove dead CSS rule for body:has([data-layout='fixed'])
- Remove unused `fixed` prop from Header, AppHeader, and Main types
- Export PageFooterPortal from layout barrel file

* 💅 refactor(web): polish table UI consistency and add pagination transitions

- Standardize primary action buttons (Create, Add, Search) to size="sm"
  across all pages for visual consistency with channels and models
- Redesign NumericSpinnerInput with minimal inline style: plain text by
  default, hover-revealed +/- buttons, click-to-edit — replacing the
  clunky bordered input with stacked chevron arrows
- Fix vertical scrollbar in channels group column by replacing
  overflow-x-auto with overflow-hidden (redundant with +N collapse)
- Simplify API keys group column: replace colorful StatusBadge pairs
  with clean typography using opacity hierarchy and dot separators
- Move API key copy loading indicator from key text to the copy button
  itself, eliminating layout shift during key resolution
- Reduce page title from text-2xl to text-lg and subtitle to text-sm
  in SectionPageLayout for a more compact header
- Add smooth opacity transition (duration-150) on all 7 server-paginated
  tables during background data fetches (isFetching && !isLoading),
  with pointer-events-none to prevent interaction during loading
- Constrain usage logs Details column width (size: 200, maxSize: 220)

* 🐛 fix(web): restore missing padding on system settings content

The console layout refactor in d2150469 moved padding ownership from
Main onto each route, but SystemSettings was missed — its Outlet
wrapper had no padding, so the content area sat flush against the
sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical
rhythm used by SectionPageLayout and the Profile page.

* 📱 refactor(web): standardize mobile responsive layout across all table pages

Unify mobile experience for all data table pages (channels, keys, models,
deployments, usage-logs, users, redemption-codes, subscriptions) with a
consistent layout pattern and cleaner header area.

DataTableToolbar:
- Redesign mobile layout: full-width search input + collapsible filter
  toggle button with active filter count badge
- Filters, additional search, and reset button collapse into an
  expandable section on mobile, keeping the default view compact
- Desktop layout remains unchanged

SectionPageLayout:
- Tighten mobile spacing (padding, gaps) for higher content density
- Scale down title (text-base) and description (text-xs) on mobile
- Shrink action button gaps on small screens

ChannelsPrimaryButtons:
- Move Tag Mode and Sort by ID toggles into the "More" dropdown on
  mobile (via DropdownMenuCheckboxItem), freeing header space
- Desktop toggle switches remain visible outside the dropdown

MobileCardList (shared component):
- Compact list-item layout with title + badge header row and
  side-by-side key fields, replacing individual card components
- Structured (CompactRow) and fallback (FallbackRow) rendering modes
  driven by column meta (mobileTitle, mobileBadge, mobileHidden)

New MobileCardList integration:
- Users table: username as title, status as badge; hide id,
  display_name, invite_info on mobile
- Redemptions table: name as title, status as badge; hide id,
  created_time, expired_time, used_user_id on mobile
- Subscriptions table: plan title as title, enabled as badge; hide id,
  sort_order, reset, payment, total_amount, upgrade_group on mobile

Column meta updates:
- Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table
  column definitions for consistent mobile field prioritization

Minor fixes:
- Hide Subscriptions Stripe/Creem alert on mobile
- Disable card hover animations on mobile via CSS media query

* 🐛 fix(web): sync favicon with custom system logo

Favicon stayed at the hardcoded /logo.png while document.title already
followed system_name, leaving tab icon and site branding out of sync.
Apply the logo as favicon from localStorage cache on startup, refresh
from getStatus(), and re-apply when useSystemConfig finishes preloading.
Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard
to avoid redundant DOM writes.

*  feat(web): add channel affinity rule templates and CreateCacheRatio visual editing

Port missing features from legacy frontend (b8650b9 merge) to the new
React frontend:

- Add Codex CLI and Claude CLI channel affinity rule templates with
  header passthrough presets (pass_headers operations for Originator,
  Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.)
- Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude
  CLI template options in the channel affinity settings page
- Add "Fill Templates" button to batch-append both CLI templates with
  duplicate name resolution and confirmation dialog
- Support templateKey prop in RuleEditorDialog to pre-fill form fields
  from selected template, auto-expanding advanced settings when a
  param_override_template is present
- Add CreateCacheRatio support to the model ratio visual editor, edit
  dialog, and form — previously only editable in JSON mode, now fully
  integrated into the visual table column, add/edit dialog fields, and
  save/delete handlers

* 🐛 fix(web): fix content-type detection bugs in About and Home pages

- Fix About page URL detection: replace naive `startsWith('https://')`
  with proper `new URL()` validation to support both http and https, and
  handle untrimmed input that previously caused silent misdetection
- Fix About page HTML detection: remove overly broad `startsWith('<')`
  and `endsWith('>')` checks that could misclassify Markdown or XML
  content; align with LegalDocument's regex-only `isLikelyHtml` approach
- Fix Home page URL detection: same `startsWith('https://')` bug,
  replaced with `new URL()` protocol validation
- Refactor About page to use early-return pattern instead of deeply
  nested ternary expressions for better readability
- Replace About loading spinner with Skeleton placeholder consistent
  with LegalDocument
- Add `prose prose-neutral dark:prose-invert` typography classes to
  About HTML/Markdown rendering for proper dark mode support
- Remove unused `Code` icon import from About page

*  feat(web): port missing features from legacy frontend and complete i18n

Backport and enhance several features from the old frontend (web/old)
that were missing or incomplete in the new React frontend:

- Playground & channel test: parse structured JSON error responses from
  SSE streams and non-streaming API calls, extract error codes, and
  display actionable UI for `model_price_error` (admin settings link)
- User management: replace local quota manipulation with atomic
  server-side quota adjustments (add/subtract/override) via dedicated
  API endpoint, making the quota field read-only in the edit drawer
- Subscriptions: display next quota reset time for active subscriptions
- Dashboard: limit model ranking chart to top 20 models with an "Other"
  bucket, add dimension tooltips with sorted values and totals to model
  call trend and user consumption trend charts
- i18n: add 24 new translation keys across all 6 locales (en, zh, fr,
  ja, ru, vi) for the newly introduced UI elements and messages

* 🎨 feat: add backend-configurable frontend theme switching (default/classic)

Introduce runtime frontend theme switching so administrators can switch
between the new frontend (Radix UI + Tailwind) and the classic frontend
(Semi Design) from the settings page without restarting the server.

Directory restructuring:
- Move new frontend from web/ to web/default/
- Move classic frontend from web/old/ to web/classic/
- One-frontend-per-folder layout for extensibility

Backend (injection pattern):
- Add setting/system_setting/theme.go with GlobalConfig.Register("theme")
  so the DB key "theme.frontend" is handled automatically by
  handleConfigUpdate — no switch-case in updateOptionMap needed
- Use atomic.Value in common.GetTheme()/SetTheme() for lock-free
  concurrent reads on the hot path (static file middleware)
- Add themeAwareFileSystem that delegates to the correct embedded FS
  based on the current theme at request time
- Embed both frontends into the binary via go:embed
- Add controller validation for theme.frontend values
- Expose theme in GET /api/status response

Frontend settings UI:
- New frontend: add "Frontend Theme" select in System Information section
  using Radix UI Select + react-hook-form + Zod validation
- Classic frontend: add "前端主题" select in Personalization section
  using Semi Design Form.Select

Build system:
- Update Dockerfile with multi-stage builds for both frontends
- Update Makefile with separate build targets for each frontend
- Update GitHub Actions release workflow for dual frontend builds

i18n:
- New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi)
- Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi)
- Fix zh "AI Proxy Library" → "AI 代理库"

Documentation:
- Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect
  the new web/default/ and web/classic/ directory structure

*  feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope

- Add `allow_speed` toggle for Anthropic (type 14) channels to control
  Claude inference speed mode passthrough, with full form schema,
  settings persistence, and UI switch
- Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels,
  not just OpenAI (type 1), matching the backend behavior for Claude data
  residency region control
- Fix multi-key management dialog to display 1-based key indices instead
  of 0-based (#{key.index + 1})
- Fix TypeScript type error in section-registry by adding type assertion
  for theme.frontend enum
- Add i18n translations for all new keys across 6 locales (en, zh, fr,
  ja, ru, vi)

* 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme

- Add .cursor/ to .gitignore and remove tracked editor config files
  (.cursor/rules/, .cursor/skills/) from version control
- Consolidate .agents/skills/vercel-react-best-practices by keeping only
  the compiled AGENTS.md and removing redundant SKILL.md and 57 individual
  rule files under rules/
- Change default frontend theme from "default" to "classic" in both
  common/constants.go init and setting/system_setting/theme.go

* feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24)

* feat(ui): add codex extra limits, key last used, and admin audit surfaces

- codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data.
- api keys table: add "Last Used" column from `accessed_time`.
- usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data.
- billing history: show user id badge for admins; add zh i18n for new strings.

* feat(web): add dynamic pricing breakdown and Waffo Pancake payments

- add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details.
- extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity.
- add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring.
- update payment confirm/recharge UI and Chinese locale strings.

* feat(pricing): add tiered billing editor and tool price settings

- introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules.
- support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders.
- add TieredPricingEditor and tool price settings UI plus i18n updates.

* chore(web): bump rsbuild to v2 and align build config

- upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock.
- replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors.
- factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION.
- TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR.

* fix(i18n): update translations for API keys and Waffo Pancake settings

- Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages.
- Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups."
- Updated various existing translations to ensure consistency and clarity in user interface text.

* refactor(code-block): simplify code highlighting and improve theme handling

- Updated the highlightCode function to support dual themes in a single call, reducing complexity.
- Removed unnecessary state management for dark theme HTML, streamlining the component.
- Enhanced CSS for Shiki themes to ensure proper token color application in dark mode.

* refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch

- replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES.
- centralize the value so a constant change does not require hunting for typos in multiple call sites.

* fix(wallet): validate waffo pancake checkout url and safe open

- allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes.
- pass noopener and noreferrer in window.open to reduce reverse tabnabbing.
- show a toast and abort on invalid URLs; add i18n entries across locales.

* fix(wallet): harden payment icon image URLs

- add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs.
- set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage.
- fall back to built-in icons on invalid URLs, same as when iconUrl is missing.

* fix(pricing): label param() conditions as body param in dynamic pricing

- non-header request rules map to `param()`, not query strings.
- align with tiered pricing editor by using the existing `Body param` string.

* fix(rsbuild): update legalComments handling in build config

- Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements.
- Clarified comments to explain the implications of omitting legalComments in production.

* fix(i18n): correct billing and codex UI strings in locale files

- restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.).
- add proper fr/ru/vi/ja strings so those locales no longer copy zh.
- change five locale files only; zh.json unchanged.

* fix(i18n): update locale files for improved translations and sync report

- Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files.
- Updated the sync report to reflect zero missing translations across multiple locales.
- Enhanced the untranslated count for Japanese locale to ensure completeness.
- Changed the base locale from zh.json to en.json for better alignment.

* chore(agents): add i18n-translate agent skill

- add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and
  `bun run i18n:sync` usage.
- capture a repeatable maintainer workflow with embedded script examples to find missing keys
  and untranslated values.
- give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi.

* feat(settings): hide frontend theme setting (#25)

* feat(settings): hide frontend theme setting

- add a local hidden feature flag with window.newapiUnlock support.
- hide the frontend theme option by default and reveal it immediately after unlock.

* feat(settings): support click unlock for frontend theme setting

- add a shared hidden click unlock hook for repeated-click gated UI.
- reveal the frontend theme option after triple-clicking the system information title.
- preserve the Doubao API address ten-click unlock behavior and remove global unlock functions.

* feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26)

* feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default

- Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default.
- Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process.
- Emphasized quality standards and internationalization considerations for new user-visible strings.

* feat(web/default): sync billing and model management features from classic

- add `len` condition variable (total input context length); introduce
  BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars
  from condition-only vars; fix tier condition regex to accept `len`.
- rewrite upstream ratio sync components to support per-model grouped
  rows and new ratio types (create_cache, image, audio, billing_expr).
- add LlmPromptHelper component; update tiered presets to use `len` for
  conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and
  weekend-discount presets.
- add created_at / last_login_at columns to users table; add "Removed
  Models" tab to FetchModelsDialog for mapping source keys not in the
  models list.
- add extractMappingSourceModels helper; update dynamic-pricing-breakdown
  to use system currency settings; add 19 i18n keys across all locales.

*  feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA

Continues the classic-to-default sync (commit 1be6cdb) by porting the
remaining audit-log, pricing-hint, and Passkey lifecycle features from
web/classic to web/default using the default frontend's component
patterns (Radix UI, Tailwind, shadcn-style dialogs).

* feat(usage-logs): show tiered_expr breakdown and matched tier in details

  - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and
    `matched_tier` fields populated by the backend for tiered logs.
  - Add `decodeBillingExprB64`, `resolveMatchedTier`, and
    `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that
    centralise tiered-billing parsing on top of the canonical
    `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing
    feature, instead of duplicating the classic-frontend renderer.
  - Render `<DynamicPricingBreakdown>` inside the consume-log details
    dialog with the matched tier row highlighted in emerald and tagged
    "Matched"; suppress the legacy claude/audio/image cost rows when a
    tiered expression is in effect.
  - Surface per-tier prices and the matched tier label in log row
    segments and the billing breakdown table.

* feat(pricing): show tier-count, time-based, and request-based hints in model list

  - Add `summarizeTieredExpr` that derives compact dynamic-pricing
    metadata (tier count + presence of time/request conditions) from a
    `tiered_expr` model, computed once per render via `useMemo`, so
    users can tell *what kind* of dynamic pricing applies before
    drilling into the model details.
  - Render the hints alongside the existing "Dynamic Pricing" badge in
    `<ModelRow>`.
  - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so
    the same component can be reused from the usage-log details dialog
    to highlight the tier that actually fired.

* feat(profile): require Security Verification for Passkey register/remove

  - Wire `usePasskeyManagement` through `useSecureVerification` and
    `<SecureVerificationDialog>` in `<PasskeyCard>`.
  - Registration prompts for 2FA before issuing the Passkey credential
    (only when 2FA is already enabled — otherwise the browser-level
    Passkey prompt itself acts as proof of presence and we register
    directly).
  - Removal prompts for 2FA or Passkey, whichever the account has
    enabled, with informative toasts when neither method is available
    or the device lacks Passkey support.
  - Scope the dialog method set to the required factor so users cannot
    fall back to a weaker method, and propagate cancellation cleanly.

* refactor: tighten upstream-ratio-sync and fix tier editor narrowing

  - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` /
    `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`.
  - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a
    null-`field` guard to silence the type checker and make the
    "pricing variables only" contract explicit.
  - Apply Prettier-consistent formatting to the upstream-ratio-sync
    table/columns, channel mutate drawer, system info section,
    tier-expr, and wallet helpers (no behaviour change).

* i18n: add 9 keys across en/zh/fr/ja/ru/vi

  - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`,
    `Request-based`, `Security verification`, `Time-based`, plus the
    two new Passkey verification description strings.

* 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9

Port and optimize the remaining web/classic features from commit 59337e9 to web/default,
covering upstream price synchronization, tiered billing expressions, model fetching, and
channel preset detection. Improve component architecture, memoization, and i18n coverage.

Upstream Price Sync
- Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio,
  AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio
  / ModelPrice
- Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so
  selecting one upstream tier value populates the other from the same source
- Bulk select / unselect per upstream column with indeterminate checkbox state
  reflecting partial selection
- Confidence indicators warn when an upstream entry is heuristically derived
- Conflict confirm dialog gains loading state and disables actions during sync
- Default endpoint per channel: /api/pricing for official preset,
  /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter,
  with the rest falling back to the global default
- Rename tab label from "Upstream sync" to "Upstream price sync" for clarity

Tiered Pricing Editor
- Add `len` (full input length, including cache hits) as a tier-condition
  variable to avoid mis-routing when cache hits reduce `p`
- When inserting a new tier, automatically convert the previous catch-all into
  a bounded tier with a `len <= X` upper bound
- Cap each tier at 0~2 conditions and disable the add-condition button at the
  limit, with an Alert explaining the recommended `len` usage
- Extend presets with Multimodal (img / img_o / ai / ao), Request rule
  (header/param matching), and Time-based (hour / weekday) entries
- Embed an LLM prompt helper that copies a model-aware template for designing
  expressions with ChatGPT / Claude

Fetch Models Dialog
- Add a "Removed Models" tab listing models still in the local selection but
  no longer returned by the upstream listing
- Exclude `model_mapping` source keys from the removed view so aliases never
  appear as missing entries
- Force-remount tab content on tab switch via `key` prop to clear stale state
- Switch count placeholders to `{{count}}` interpolation across "Existing
  Models", "New Models", and "Removed Models" labels

Channel Selector & Constants
- Recognize the models.dev preset (id, base_url, name) alongside the existing
  official-channel preset detection
- Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder
  ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config`
- Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter

Types
- Add optional `type` field to UpstreamChannel for endpoint inference
- Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio,
  audio_completion_ratio, billing_mode, and billing_expr

Code Quality & Performance
- Extract upstream-ratio-sync-helpers.ts to host shared types
  (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering
  (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection
  logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel)
- Memoize the column definitions in useUpstreamRatioSyncColumns and pull the
  per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs
- Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs;
  rename the misleading `_upstream` parameter to `upstream`
- Convert parsedRatios from useCallback (returning a function) to useMemo
  (returning the value) and update all call sites to read it as a value
- Memoize the channels list with useMemo so the endpoint-init effect no
  longer fires on every render due to a fresh `?? []` reference

i18n
- Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio
  Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression
  Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.)
  across en, zh, fr, ja, ru, vi
- Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (")
  to proper {{count}} interpolated forms in every locale
- bun run i18n:sync reports 0 missing and 0 extra keys for every locale

Verification
- bun run typecheck: pass
- bun run lint: pass
- bun run i18n:sync: pass (0 missing / 0 extras across all locales)

* 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys

Sync the web/classic frontend fixes from upstream merge 73e5557 to
web/default, and clean up duplicated Title-Case ratio labels in the
upstream sync UI that were shadowing the canonical sentence-case i18n
keys.

Cache-token filter for tiered model price (port of 9f8a4ec05)
- The matched-tier breakdown shown in the usage-log details dialog
  and in the log table previously listed every cache-related price
  (Cache Read, Cache Write, Cache Write 1h) regardless of whether
  the request actually consumed cache tokens.
- `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips
  `cache`-group vars when none of `cache_tokens`,
  `cache_creation_tokens`, `cache_creation_tokens_5m`, or
  `cache_creation_tokens_1h` are positive, mirroring the classic
  `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic.
- Extract `hasAnyCacheTokens(other)` as an exported helper so the
  predicate is defined once.
- Add a `hideCacheColumns?: boolean` prop to
  `DynamicPricingBreakdown` and wire it up from the log details
  dialog so the full tier table hides cache columns under the same
  condition. `model-details.tsx` keeps the default (show all
  configured prices), since that view represents the model's
  pricing structure rather than a specific call.

`tiered_expr` ratio/price fallback during sync delays (port of bee339d27)
- When saving a model in tiered-expression mode, the visual editor
  used to delete every ratio/price map entry for the model and only
  write `billing_setting.billing_mode` /
  `billing_setting.billing_expr`. In multi-instance deployments,
  instances that had not yet observed the billing_setting update
  fell back to ratios that no longer existed, breaking pricing.
- `model-ratio-dialog.tsx`: `handleSubmit` always passes every form
  field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`,
  `completionRatio`, `imageRatio`, `audioRatio`,
  `audioCompletionRatio`) into the data object regardless of
  `pricingMode`, so a switch from per-token to tiered_expr no
  longer drops the previously entered ratios.
- `model-ratio-visual-editor.tsx`:
  - The row builder now also surfaces ratio/price values for
    `tiered_expr` rows, so they survive the edit-and-save round
    trip and the next save.
  - `handleSave` factors out a `setIfPresent` helper and persists
    ratio/price entries for `tiered_expr` models alongside
    billing_mode / billing_expr. These act purely as fallback
    because the backend's `ModelPriceHelper` checks `billing_mode`
    first.
  - Cell rendering mutes ratio/price values whenever the row is
    `tiered_expr` (in addition to the existing per-request
    muting), making it visually clear the values are fallback,
    not the active pricing source.

i18n: dedupe Title-Case ratio labels in upstream sync
- `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used
  Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio
  Completion Ratio`, …) that were rendered through `t()` but never
  existed as canonical keys in the catalogue. The form-field side
  has used sentence-case (`Model ratio`, `Cache ratio`, …) for
  some time, leaving two parallel translation entries per ratio
  type and causing the upstream sync UI to fall back to the
  English source string in zh/ja/ru/fr/vi.
- Rewrite `RATIO_TYPE_OPTIONS` in
  `system-settings/models/constants.ts` and the conflict-detection
  labels in `upstream-ratio-sync.tsx` to reuse the sentence-case
  keys.
- Drop the duplicate Title-Case entries from every locale and
  promote the better translations onto the surviving sentence-case
  keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion
  ratio` keeps "音频补全倍率").
- Add a comment to `RATIO_TYPE_OPTIONS` warning future
  contributors not to switch back to Title Case without updating
  the catalogue.

Note on backend fix 4e93148d9
- The backend portion of the merge (allocating a fresh map in
  `updateConfigFromMap` so removed keys are properly cleared) is
  already on HEAD; no additional change is needed.

Verification
- `bun run typecheck`: pass
- `bun run lint`: pass
- `bun run i18n:sync`: 0 missing / 0 extras across
  en / zh / fr / ja / ru / vi

---------

Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com>
Co-authored-by: Seefs <i@seefs.me>
Co-authored-by: feitianbubu <feitianbubu@qq.com>
Co-authored-by: Calcium-Ion <i@caion.me>
Co-authored-by: Xyfacai <xyfacai@gmail.com>
Co-authored-by: xiangsx <1984871009@qq.com>
Co-authored-by: 郑伯涛 <351175318@qq.com>
Co-authored-by: RedwindA <austinaosid@gmail.com>
Co-authored-by: dean <1006393151@qq.com>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
Co-authored-by: Bliod <bliod@bliod.lan>
Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
同語
2026-04-28 14:19:19 +08:00
committed by GitHub
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
+12
View File
@@ -0,0 +1,12 @@
import { api } from '@/lib/api'
import type { PricingData } from './types'
// ----------------------------------------------------------------------------
// Pricing APIs
// ----------------------------------------------------------------------------
// Get model pricing data
export async function getPricing(): Promise<PricingData> {
const res = await api.get('/api/pricing')
return res.data
}
@@ -0,0 +1,334 @@
import { useMemo } from 'react'
import { Tag as TagIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
BILLING_PRICING_VARS,
MATCH_CONTAINS,
MATCH_EQ,
MATCH_EXISTS,
MATCH_GTE,
MATCH_LT,
MATCH_RANGE,
SOURCE_TIME,
parseTiersFromExpr,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
type ParsedTier,
type RequestCondition,
type RequestRuleGroup,
type TierCondition,
} from '../lib/billing-expr'
type DynamicPricingBreakdownProps = {
billingExpr: string | null | undefined
/**
* Label of the tier that fired for the current request. When provided,
* the corresponding row is highlighted and tagged as "Matched". Used by
* the usage-log details dialog to show which tier the engine selected.
*/
matchedTierLabel?: string | null
/**
* Hide cache-pricing columns regardless of the per-tier values. The log
* details dialog passes this when the actual request did not consume any
* cache tokens, so users only see pricing rows that were relevant to the
* call they are inspecting. Defaults to false (show all configured prices).
*/
hideCacheColumns?: boolean
}
const VAR_LABELS: Record<string, string> = {
p: 'Input',
c: 'Output',
len: 'Length',
}
const OP_LABELS: Record<string, string> = {
'<': '<',
'<=': '≤',
'>': '>',
'>=': '≥',
}
const TIME_FUNC_LABELS: Record<string, string> = {
hour: 'Hour',
minute: 'Minute',
weekday: 'Weekday',
month: 'Month',
day: 'Day',
}
function formatTokenHint(value: string | number): string {
const n = Number(value)
if (!Number.isFinite(n) || n === 0) return ''
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
}
if (n >= 1000) {
return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`
}
return String(n)
}
function formatConditionSummary(
conditions: TierCondition[],
t: (key: string) => string
): string {
return conditions
.map((c) => {
const varLabel = t(VAR_LABELS[c.var] || c.var)
const hint = formatTokenHint(c.value)
return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}`
})
.filter(Boolean)
.join(' && ')
}
function describeCondition(
cond: RequestCondition,
t: (key: string) => string
): string {
if (cond.source === SOURCE_TIME) {
const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc)
const tz = cond.timezone || 'UTC'
if (cond.mode === MATCH_RANGE) {
return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`
}
const opMap: Record<string, string> = {
[MATCH_EQ]: '=',
[MATCH_GTE]: '≥',
[MATCH_LT]: '<',
}
return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`
}
const src = cond.source === 'header' ? t('Header') : t('Body param')
const path = cond.path || ''
if (cond.mode === MATCH_EXISTS) return `${src} ${path} ${t('Exists')}`
if (cond.mode === MATCH_CONTAINS) {
return `${src} ${path} ${t('Contains')} "${cond.value}"`
}
const opMap: Record<string, string> = {
eq: '=',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
}
return `${src} ${path} ${opMap[cond.mode] || '='} ${cond.value}`
}
function describeGroup(
group: RequestRuleGroup,
t: (key: string) => string
): string {
return (group.conditions || [])
.map((c) => describeCondition(c, t))
.join(' && ')
}
export function DynamicPricingBreakdown({
billingExpr,
matchedTierLabel,
hideCacheColumns = false,
}: DynamicPricingBreakdownProps) {
const { t } = useTranslation()
const expr = billingExpr || ''
const currency = useSystemConfigStore((s) => s.config.currency)
const { symbol, rate } = useMemo(() => {
if (currency.quotaDisplayType === 'CNY') {
return { symbol: '¥', rate: currency.usdExchangeRate || 7 }
}
if (currency.quotaDisplayType === 'CUSTOM') {
return {
symbol: currency.customCurrencySymbol || '¤',
rate: currency.customCurrencyExchangeRate || 1,
}
}
return { symbol: '$', rate: 1 }
}, [currency])
const priceSuffix = `${symbol}/1M tokens`
const { tiers, ruleGroups, baseExpr } = useMemo(() => {
const split = splitBillingExprAndRequestRules(expr)
const parsedTiers = parseTiersFromExpr(split.billingExpr)
const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
return {
baseExpr: split.billingExpr,
tiers: parsedTiers,
ruleGroups: parsedRules || [],
}
}, [expr])
const hasTiers = tiers.length > 0
const hasRules = ruleGroups.length > 0
if (!expr) return null
if (!hasTiers && !hasRules) {
return (
<section className='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>
<code className='text-muted-foreground block text-xs break-all'>
{baseExpr || expr}
</code>
</section>
)
}
const visiblePriceFields = BILLING_PRICING_VARS.filter((v) => {
if (!hasTiers) return false
if (hideCacheColumns && v.group === 'cache') return false
return tiers.some(
(tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0
)
})
return (
<section className='py-4'>
<div className='mb-4 flex items-start gap-2'>
<span className='mt-0.5 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>
<div>
<div className='text-foreground text-base font-medium'>
{t('Dynamic Pricing')}
</div>
<div className='text-muted-foreground text-xs'>
{t('Prices vary by usage tier and request conditions')}
</div>
</div>
</div>
{hasTiers && (
<div className='mb-4'>
<div className='text-foreground mb-2 text-sm font-semibold'>
{t('Tiered price table')}
</div>
<div className='-mx-4 sm:mx-0'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'>
{t('Tier')}
</TableHead>
{visiblePriceFields.map((v) => (
<TableHead
key={v.field}
className='text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase'
>
{`${t(v.shortLabel)} (${priceSuffix})`}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tiers.map((tier, i) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
matchedTierLabel != null &&
matchedTierLabel !== '' &&
tier.label === matchedTierLabel
return (
<TableRow
key={`tier-${i}`}
className={cn(
isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
)}
>
<TableCell className='py-2.5 align-top'>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
>
{tier.label || t('Default')}
</Badge>
{isMatched && (
<Badge
variant='secondary'
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
>
{t('Matched')}
</Badge>
)}
</div>
{condSummary && (
<div className='text-muted-foreground mt-1 text-xs'>
{condSummary}
</div>
)}
</TableCell>
{visiblePriceFields.map((v) => {
const value = Number(
tier[v.field as string as keyof ParsedTier] || 0
)
return (
<TableCell
key={v.field}
className='py-2.5 text-right align-top font-mono'
>
{value > 0 ? (
<span className='font-semibold'>
{`${symbol}${(value * rate).toFixed(4)}`}
</span>
) : (
'-'
)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)}
{hasRules && (
<div>
<div className='text-foreground mb-2 text-sm font-semibold'>
{t('Conditional multipliers')}
</div>
<ul className='space-y-1.5'>
{ruleGroups.map((group, gi) => (
<li
key={`group-${gi}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
<span className='text-foreground text-sm break-all'>
{describeGroup(group, t)}
</span>
<Badge
variant='secondary'
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
>
{group.multiplier}x
</Badge>
</li>
))}
</ul>
</div>
)}
</section>
)
}
@@ -0,0 +1,39 @@
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
export interface EmptyStateProps {
searchQuery?: string
hasActiveFilters: boolean
onClearFilters: () => void
}
export function EmptyState(props: EmptyStateProps) {
const { t } = useTranslation()
const hasSearch = Boolean(props.searchQuery?.trim())
return (
<div className='flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center'>
<Search className='text-muted-foreground/40 mb-3 size-10' />
<h3 className='text-foreground mb-1 text-base font-semibold'>
{t('No models found')}
</h3>
<p className='text-muted-foreground mb-5 max-w-xs text-sm'>
{hasSearch
? t(
'No results for "{{query}}". Try adjusting your search or filters.',
{ query: props.searchQuery }
)
: t('No models match your current filters.')}
</p>
{(props.hasActiveFilters || hasSearch) && (
<Button variant='outline' size='sm' onClick={props.onClearFilters}>
{t('Clear all filters')}
</Button>
)}
</div>
)
}
@@ -0,0 +1,729 @@
import { useCallback, useMemo, useState } from 'react'
import {
ArrowUpDown,
Check,
ChevronDown,
Filter,
List,
RotateCcw,
Table2,
X,
} 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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
FILTER_ALL,
QUOTA_TYPES,
ENDPOINT_TYPES,
VIEW_MODES,
getSortLabels,
getQuotaTypeLabels,
getEndpointTypeLabels,
type SortOption,
type ViewMode,
} from '../constants'
import type { PricingVendor, TokenUnit } from '../types'
interface FilterOption {
value: string
label: string
icon?: React.ReactNode
}
interface FilterChipProps {
label: string
value: string
options: FilterOption[]
onChange: (value: string) => void
defaultValue?: string
searchPlaceholder?: string
className?: string
}
function FilterChip(props: FilterChipProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const defaultVal = props.defaultValue ?? FILTER_ALL
const isActive = props.value !== defaultVal
const selectedOption = props.options.find((o) => o.value === props.value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type='button'
role='combobox'
aria-expanded={open}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
isActive
? 'border-foreground/30 bg-foreground/5 text-foreground'
: 'border-border bg-muted/50 text-muted-foreground hover:border-border/80 hover:text-foreground hover:bg-muted',
props.className
)}
>
{selectedOption?.icon}
<span className='max-w-[100px] truncate'>
{isActive && selectedOption ? selectedOption.label : props.label}
</span>
<ChevronDown
className={cn(
'size-3.5 transition-transform',
open && 'rotate-180'
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className='w-52 p-0' align='start'>
<Command>
<CommandInput
placeholder={props.searchPlaceholder || t('Search...')}
className='h-9'
/>
<CommandList className='max-h-64'>
<CommandEmpty>{t('No results found')}</CommandEmpty>
<CommandGroup>
{props.options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
props.onChange(option.value)
setOpen(false)
}}
className='gap-2'
>
<Check
className={cn(
'size-4 shrink-0',
props.value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && (
<span className='shrink-0'>{option.icon}</span>
)}
<span className='truncate'>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
interface SegmentedControlProps {
options: Array<{
value: string
label?: string
icon?: React.ComponentType<{ className?: string }>
tooltip?: string
}>
value: string
onChange: (value: string) => void
ariaLabel?: string
}
function SegmentedControl(props: SegmentedControlProps) {
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 isActive = option.value === props.value
const Icon = option.icon
const isIconOnly = Icon && !option.label
const button = (
<button
key={option.value}
type='button'
onClick={() => props.onChange(option.value)}
className={cn(
'inline-flex h-[calc(100%)] items-center justify-center rounded-[5px] text-xs font-medium transition-all',
isIconOnly ? 'w-7' : 'gap-1.5 px-2.5',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={isActive}
>
{Icon && <Icon className='size-3.5' />}
{option.label}
</button>
)
if (option.tooltip) {
return (
<Tooltip key={option.value}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side='bottom' className='text-xs'>
{option.tooltip}
</TooltipContent>
</Tooltip>
)
}
return button
})}
</div>
)
}
interface ActiveFilterBadgeProps {
label: string
onRemove: () => void
}
function ActiveFilterBadge(props: ActiveFilterBadgeProps) {
const { t } = useTranslation()
return (
<Badge
variant='secondary'
className='gap-1 py-1 pr-1 pl-2.5 text-xs font-medium'
>
{props.label}
<button
type='button'
onClick={props.onRemove}
className='hover:bg-secondary-foreground/10 rounded-full p-0.5 transition-colors'
aria-label={`${t('Remove filter')}: ${props.label}`}
>
<X className='size-3' />
</button>
</Badge>
)
}
interface MobileFilterGroupProps {
title: string
value: string
options: FilterOption[]
onChange: (value: string) => void
}
function MobileFilterGroup(props: MobileFilterGroupProps) {
return (
<div className='space-y-2.5'>
<h3 className='text-foreground text-sm font-semibold'>{props.title}</h3>
<div className='flex flex-wrap gap-2'>
{props.options.map((option) => {
const isActive = props.value === option.value
return (
<button
key={option.value}
type='button'
onClick={() => props.onChange(option.value)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
isActive
? 'border-foreground/30 bg-foreground/5 text-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground border-transparent'
)}
>
{option.icon}
{option.label}
</button>
)
})}
</div>
</div>
)
}
export interface FilterBarProps {
quotaTypeFilter: string
endpointTypeFilter: string
vendorFilter: string
groupFilter: string
tagFilter: string
onQuotaTypeChange: (v: string) => void
onEndpointTypeChange: (v: string) => void
onVendorChange: (v: string) => void
onGroupChange: (v: string) => void
onTagChange: (v: string) => void
vendors: PricingVendor[]
groups: string[]
tags: string[]
sortBy: string
onSortChange: (v: string) => void
tokenUnit: TokenUnit
onTokenUnitChange: (v: TokenUnit) => void
showRechargePrice: boolean
onRechargePriceChange: (v: boolean) => void
viewMode: ViewMode
onViewModeChange: (v: ViewMode) => void
hasActiveFilters: boolean
activeFilterCount: number
onClearFilters: () => void
filteredCount: number
totalCount?: number
}
export function FilterBar(props: FilterBarProps) {
const { t } = useTranslation()
const [mobileOpen, setMobileOpen] = useState(false)
const {
quotaTypeFilter,
endpointTypeFilter,
vendorFilter,
groupFilter,
tagFilter,
onQuotaTypeChange,
onEndpointTypeChange,
onVendorChange,
onGroupChange,
onTagChange,
vendors,
groups,
tags,
onTokenUnitChange,
onViewModeChange,
onRechargePriceChange,
} = props
const quotaTypeLabels = getQuotaTypeLabels(t)
const endpointTypeLabels = getEndpointTypeLabels(t)
const sortLabels = getSortLabels(t)
const typeOptions = useMemo<FilterOption[]>(
() =>
Object.entries(quotaTypeLabels).map(([value, label]) => ({
value,
label,
})),
[quotaTypeLabels]
)
const endpointOptions = useMemo<FilterOption[]>(
() =>
Object.entries(endpointTypeLabels).map(([value, label]) => ({
value,
label,
})),
[endpointTypeLabels]
)
const vendorOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Vendors') },
...vendors.map((v) => ({
value: v.name,
label: v.name,
icon: v.icon ? getLobeIcon(v.icon, 14) : undefined,
})),
],
[vendors, t]
)
const groupOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Groups') },
...groups.map((g) => ({ value: g, label: g })),
],
[groups, t]
)
const tagOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Tags') },
...tags.map((tag) => ({ value: tag, label: tag })),
],
[tags, t]
)
const activeFilters = useMemo(() => {
const filters: Array<{ key: string; label: string; onRemove: () => void }> =
[]
if (quotaTypeFilter !== QUOTA_TYPES.ALL) {
filters.push({
key: 'quotaType',
label:
quotaTypeLabels[quotaTypeFilter as keyof typeof quotaTypeLabels] ||
quotaTypeFilter,
onRemove: () => onQuotaTypeChange(QUOTA_TYPES.ALL),
})
}
if (endpointTypeFilter !== ENDPOINT_TYPES.ALL) {
filters.push({
key: 'endpointType',
label:
endpointTypeLabels[
endpointTypeFilter as keyof typeof endpointTypeLabels
] || endpointTypeFilter,
onRemove: () => onEndpointTypeChange(ENDPOINT_TYPES.ALL),
})
}
if (vendorFilter !== FILTER_ALL) {
filters.push({
key: 'vendor',
label: vendorFilter,
onRemove: () => onVendorChange(FILTER_ALL),
})
}
if (groupFilter !== FILTER_ALL) {
filters.push({
key: 'group',
label: groupFilter,
onRemove: () => onGroupChange(FILTER_ALL),
})
}
if (tagFilter !== FILTER_ALL) {
filters.push({
key: 'tag',
label: tagFilter,
onRemove: () => onTagChange(FILTER_ALL),
})
}
return filters
}, [
quotaTypeFilter,
endpointTypeFilter,
vendorFilter,
groupFilter,
tagFilter,
onQuotaTypeChange,
onEndpointTypeChange,
onVendorChange,
onGroupChange,
onTagChange,
quotaTypeLabels,
endpointTypeLabels,
])
const handleTokenUnitChange = useCallback(
(v: string) => onTokenUnitChange(v as TokenUnit),
[onTokenUnitChange]
)
const handleViewModeChange = useCallback(
(v: string) => onViewModeChange(v as ViewMode),
[onViewModeChange]
)
const handleRechargePriceChange = useCallback(
(v: string) => onRechargePriceChange(v === 'recharge'),
[onRechargePriceChange]
)
return (
<div className='space-y-3'>
<div className='flex items-center gap-3'>
<div className='hidden flex-1 flex-wrap items-center gap-2 lg:flex'>
<FilterChip
label={t('Pricing')}
value={props.quotaTypeFilter}
options={typeOptions}
onChange={props.onQuotaTypeChange}
defaultValue={QUOTA_TYPES.ALL}
/>
<FilterChip
label={t('Endpoint')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
defaultValue={ENDPOINT_TYPES.ALL}
/>
{props.vendors.length > 0 && (
<FilterChip
label={t('Vendor')}
value={props.vendorFilter}
options={vendorOptions}
onChange={props.onVendorChange}
searchPlaceholder={t('Search vendors...')}
/>
)}
{props.groups.length > 0 && (
<FilterChip
label={t('Group')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
searchPlaceholder={t('Search groups...')}
/>
)}
{props.tags.length > 0 && (
<FilterChip
label={t('Tag')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
searchPlaceholder={t('Search tags...')}
/>
)}
</div>
<Button
variant='outline'
size='sm'
onClick={() => setMobileOpen(true)}
className='gap-1.5 lg:hidden'
>
<Filter className='size-4' />
{t('Filters')}
{props.activeFilterCount > 0 && (
<Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
{props.activeFilterCount}
</Badge>
)}
</Button>
<div className='flex-1 lg:hidden' />
<div className='flex items-center gap-1.5'>
<div className='hidden items-center gap-1.5 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 className='bg-border mx-0.5 h-4 w-px' />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-8 gap-1.5 px-2.5 text-xs font-medium'
>
<ArrowUpDown className='size-3.5' />
<span className='hidden sm:inline'>
{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.LIST, icon: List, tooltip: t('List view') },
{
value: VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]}
value={props.viewMode}
onChange={handleViewModeChange}
ariaLabel={t('View mode')}
/>
</div>
</div>
{activeFilters.length > 0 && (
<div className='flex flex-wrap items-center gap-2'>
{activeFilters.map((filter) => (
<ActiveFilterBadge
key={filter.key}
label={filter.label}
onRemove={filter.onRemove}
/>
))}
<Button
variant='ghost'
size='sm'
onClick={props.onClearFilters}
className='text-muted-foreground hover:text-foreground h-6 gap-1 px-2 text-xs'
>
<RotateCcw className='size-3' />
{t('Clear all')}
</Button>
</div>
)}
<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'>
{t('of')} {props.totalCount.toLocaleString()}
</span>
)}
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<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('Filters')}</SheetTitle>
<SheetDescription className='sr-only'>
{t('Filter models by type, endpoint, vendor, group and tags')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-6 overflow-y-auto px-6 py-5'>
<MobileFilterGroup
title={t('Pricing Type')}
value={props.quotaTypeFilter}
options={typeOptions}
onChange={props.onQuotaTypeChange}
/>
<MobileFilterGroup
title={t('Endpoint Type')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
/>
{props.vendors.length > 0 && (
<MobileFilterGroup
title={t('Vendor')}
value={props.vendorFilter}
options={vendorOptions}
onChange={props.onVendorChange}
/>
)}
{props.groups.length > 0 && (
<MobileFilterGroup
title={t('Group')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
)}
{props.tags.length > 0 && (
<MobileFilterGroup
title={t('Tag')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
/>
)}
<div className='border-t pt-5'>
<h3 className='text-foreground mb-3 text-sm font-semibold'>
{t('Display Options')}
</h3>
<div className='space-y-4'>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t('Price display')}
</p>
<SegmentedControl
options={[
{ value: 'standard', label: t('Standard') },
{ value: 'recharge', label: t('Recharge') },
]}
value={props.showRechargePrice ? 'recharge' : 'standard'}
onChange={handleRechargePriceChange}
ariaLabel={t('Price display mode')}
/>
</div>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t('Token unit')}
</p>
<SegmentedControl
options={[
{ value: 'M', label: t('Per 1M tokens') },
{ value: 'K', label: t('Per 1K tokens') },
]}
value={props.tokenUnit}
onChange={handleTokenUnitChange}
ariaLabel={t('Token unit')}
/>
</div>
</div>
</div>
</div>
<SheetFooter className='border-t px-6 py-4'>
<div className='flex w-full gap-3'>
{props.hasActiveFilters && (
<Button
variant='outline'
onClick={props.onClearFilters}
className='flex-1'
>
{t('Reset')}
</Button>
)}
<Button onClick={() => setMobileOpen(false)} className='flex-1'>
{t('Show')} {props.filteredCount} {t('models')}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export { FilterBar } from './filter-bar'
export { ModelRow } from './model-row'
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 { PricingTable } from './pricing-table'
@@ -0,0 +1,130 @@
import { Skeleton } from '@/components/ui/skeleton'
import { VIEW_MODES, type ViewMode } from '../constants'
export interface LoadingSkeletonProps {
viewMode?: ViewMode
}
export function LoadingSkeleton(props: LoadingSkeletonProps) {
const viewMode = props.viewMode ?? VIEW_MODES.LIST
return (
<div className='space-y-5'>
<div className='space-y-1.5'>
<Skeleton className='h-8 w-40' />
<Skeleton className='h-4 w-52' />
</div>
<Skeleton className='h-10 w-full rounded-lg' />
<FilterBarSkeleton />
{viewMode === VIEW_MODES.TABLE ? (
<TableContentSkeleton />
) : (
<ListContentSkeleton />
)}
</div>
)
}
function FilterBarSkeleton() {
return (
<div className='space-y-3'>
<div className='flex items-center gap-3'>
<div className='flex flex-1 flex-wrap items-center gap-2'>
{[80, 90, 75, 85, 70].map((width, i) => (
<Skeleton
key={i}
className='h-8 rounded-full'
style={{ width: `${width}px` }}
/>
))}
</div>
<div className='flex items-center gap-2'>
<Skeleton className='h-8 w-24 rounded-lg' />
<Skeleton className='h-8 w-20 rounded-lg' />
<Skeleton className='h-8 w-24' />
<Skeleton className='h-8 w-20 rounded-lg' />
</div>
</div>
<Skeleton className='h-5 w-24' />
</div>
)
}
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 },
{ width: 100 },
{ width: 100 },
{ width: 100 },
{ width: 80 },
{ width: 100 },
]
return (
<div className='space-y-4'>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex items-center gap-4'>
{columns.map((col, i) => (
<Skeleton
key={i}
className='h-4'
style={{ width: `${col.width}px` }}
/>
))}
</div>
</div>
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className='flex items-center gap-4 border-b px-4 py-3 last:border-b-0'
>
{columns.map((col, j) => (
<Skeleton
key={j}
className='h-5'
style={{ width: `${col.width}px` }}
/>
))}
</div>
))}
</div>
<div className='flex items-center justify-between'>
<Skeleton className='h-5 w-32' />
<div className='flex items-center gap-2'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='size-8' />
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,589 @@
import { useMemo } from 'react'
import { useParams, useNavigate, useSearch } from '@tanstack/react-router'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { CopyButton } from '@/components/copy-button'
import { PublicLayout } from '@/components/layout'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import { parseTags } from '../lib/filters'
import {
getAvailableGroups,
replaceModelInPath,
isTokenBasedModel,
} from '../lib/model-helpers'
import { formatGroupPrice, formatFixedPrice } from '../lib/price'
import type { PricingModel, TokenUnit, PriceType } from '../types'
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
function SectionTitle(props: { children: React.ReactNode }) {
return (
<h2 className='text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase'>
{props.children}
</h2>
)
}
function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 20)
: null
const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags)
return (
<header className='pb-5'>
<div className='flex items-center gap-2.5'>
{vendorIcon}
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
{model.model_name}
</h1>
<CopyButton
value={model.model_name || ''}
className='size-6'
iconClassName='size-3'
tooltip={t('Copy model name')}
successTooltip={t('Copied!')}
aria-label={t('Copy model name')}
/>
</div>
<div className='mt-1 flex items-center gap-1.5 text-xs'>
{model.vendor_name && (
<span className='text-muted-foreground'>{model.vendor_name}</span>
)}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/50'>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
</span>
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
<>
<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>
</>
)}
</div>
{description && (
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
{description}
</p>
)}
{tags.length > 0 && (
<div className='mt-2.5 flex flex-wrap gap-1'>
{tags.map((tag) => (
<span
key={tag}
className='bg-muted text-muted-foreground rounded px-2 py-0.5 text-[11px] font-medium'
>
{tag}
</span>
))}
</div>
)}
</header>
)
}
function PriceSection(props: {
model: PricingModel
priceRate: number
usdExchangeRate: number
tokenUnit: TokenUnit
showRechargePrice: boolean
groupRatio: Record<string, number>
}) {
const { t } = useTranslation()
const {
model,
priceRate,
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 priceTypes: { label: string; type: PriceType; available: boolean }[] = [
{ label: t('Input'), type: 'input', available: true },
{
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',
available: model.create_cache_ratio != null,
},
{
label: t('Image input'),
type: 'image',
available: model.image_ratio != null,
},
{
label: t('Audio input'),
type: 'audio_input',
available: model.audio_ratio != null,
},
{
label: t('Audio output'),
type: 'audio_output',
available:
model.audio_ratio != null && model.audio_completion_ratio != null,
},
]
if (!isTokenBased) {
return (
<section className='border-b py-4'>
<SectionTitle>{t('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(
model,
groupKey,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatioMap
)}
</span>
</div>
</section>
)
}
const items = priceTypes.filter((p) => p.available)
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>
</div>
))}
</div>
</section>
)
}
function EndpointsSection(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
const { t } = useTranslation()
const { model, endpointMap } = props
const endpoints = useMemo(() => {
const types = model.supported_endpoint_types || []
return types.map((type) => {
const info = endpointMap[type] || {}
let path = info.path || ''
if (path.includes('{model}')) {
path = replaceModelInPath(path, model.model_name || '')
}
return { type, path, method: info.method || 'POST' }
})
}, [model, endpointMap])
if (endpoints.length === 0) return null
return (
<section className='border-b py-4'>
<SectionTitle>{t('API Endpoints')}</SectionTitle>
<div className='space-y-1'>
{endpoints.map(({ type, path, method }) => (
<div key={type} className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{type}</span>
{path && (
<code className='text-muted-foreground/60 text-xs break-all'>
{path}
</code>
)}
</div>
{path && (
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase'>
{method}
</span>
)}
</div>
))}
</div>
</section>
)
}
function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
const { t } = useTranslation()
const modelEnableGroups = Array.isArray(props.model.enable_groups)
? props.model.enable_groups
: []
const autoChain = props.autoGroups.filter((g) =>
modelEnableGroups.includes(g)
)
if (autoChain.length === 0) return null
return (
<div className='text-muted-foreground mb-3 flex flex-wrap items-center gap-1 text-xs'>
<span className='font-medium'>{t('Auto Group Chain')}</span>
<span className='text-muted-foreground/40'></span>
{autoChain.map((g, idx) => (
<span key={g} className='flex items-center gap-1'>
<span className='bg-muted text-foreground rounded px-1.5 py-0.5 text-[11px] font-medium'>
{g}
</span>
{idx < autoChain.length - 1 && (
<span className='text-muted-foreground/40'></span>
)}
</span>
))}
</div>
)
}
function GroupPricingSection(props: {
model: PricingModel
groupRatio: Record<string, number>
usableGroup: Record<string, { desc: string; ratio: number }>
autoGroups: string[]
priceRate: number
usdExchangeRate: number
tokenUnit: TokenUnit
showRechargePrice?: boolean
}) {
const { t } = useTranslation()
const {
model,
groupRatio,
usableGroup,
autoGroups,
priceRate,
usdExchangeRate,
tokenUnit,
showRechargePrice = false,
} = props
const availableGroups = useMemo(
() => getAvailableGroups(model, usableGroup || {}),
[model, usableGroup]
)
const isTokenBased = isTokenBasedModel(model)
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const extraPriceTypes = useMemo(() => {
const types: { label: string; type: PriceType }[] = []
if (model.cache_ratio != null)
types.push({ label: t('Cache'), type: 'cache' })
if (model.create_cache_ratio != null)
types.push({ label: t('Cache Write'), type: 'create_cache' })
if (model.image_ratio != null)
types.push({ label: t('Image'), type: 'image' })
if (model.audio_ratio != null)
types.push({ label: t('Audio In'), type: 'audio_input' })
if (model.audio_ratio != null && model.audio_completion_ratio != null)
types.push({ label: t('Audio Out'), type: 'audio_output' })
return types
}, [model, t])
if (availableGroups.length === 0) {
return (
<section className='py-4'>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<p className='text-muted-foreground text-sm'>
{t(
'This model is not available in any group, or no group pricing information is configured.'
)}
</p>
</section>
)
}
const thClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return (
<section className='py-4'>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<div className='-mx-4 sm:mx-0'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Group')}</TableHead>
<TableHead className={thClass}>{t('Ratio')}</TableHead>
{isTokenBased ? (
<>
<TableHead className={`${thClass} text-right`}>
{t('Input')}
</TableHead>
<TableHead className={`${thClass} text-right`}>
{t('Output')}
</TableHead>
{extraPriceTypes.map((ep) => (
<TableHead
key={ep.type}
className={`${thClass} text-right`}
>
{ep.label}
</TableHead>
))}
</>
) : (
<TableHead className={`${thClass} text-right`}>
{t('Price')}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{availableGroups.map((group) => {
const ratio = groupRatio[group] || 1
return (
<TableRow key={group}>
<TableCell className='py-2.5 font-medium'>{group}</TableCell>
<TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
{ratio}x
</TableCell>
{isTokenBased ? (
<>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
model,
group,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
model,
group,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
)}
</TableCell>
{extraPriceTypes.map((ep) => (
<TableCell
key={ep.type}
className='py-2.5 text-right font-mono'
>
{formatGroupPrice(
model,
group,
ep.type,
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
)}
</TableCell>
))}
</>
) : (
<TableCell className='py-2.5 text-right font-mono'>
{formatFixedPrice(
model,
group,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
)}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
{isTokenBased && (
<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>
)
}
export function ModelDetails() {
const { t } = useTranslation()
const { modelId } = useParams({ from: '/pricing/$modelId/' })
const search = useSearch({ from: '/pricing/$modelId/' })
const navigate = useNavigate()
const {
models,
groupRatio,
usableGroup,
endpointMap,
autoGroups,
isLoading,
priceRate,
usdExchangeRate,
} = usePricingData()
const tokenUnit: TokenUnit =
search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
const model = useMemo(() => {
if (!models || !modelId) return null
return models.find((m) => m.model_name === modelId) || null
}, [models, modelId])
const handleBack = () => {
navigate({ to: '/pricing', search })
}
if (isLoading) {
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
<Skeleton className='mb-4 h-5 w-16' />
<div className='space-y-2'>
<Skeleton className='h-7 w-64' />
<Skeleton className='h-4 w-40' />
<Skeleton className='h-4 w-full max-w-md' />
</div>
<div className='mt-6 space-y-3'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex justify-between'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-5 w-20' />
</div>
))}
</div>
</div>
</PublicLayout>
)
}
if (!model) {
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 text-center sm:px-6'>
<h2 className='mb-1 text-base font-semibold'>
{t('Model not found')}
</h2>
<p className='text-muted-foreground mb-4 text-sm'>
{t("The model you're looking for doesn't exist.")}
</p>
<Button onClick={handleBack} variant='outline' size='sm'>
{t('Back to Models')}
</Button>
</div>
</PublicLayout>
)
}
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
<Button
variant='ghost'
size='sm'
onClick={handleBack}
className='text-muted-foreground hover:text-foreground mb-4 h-auto gap-1 px-0 py-1 text-xs'
>
<ArrowLeft className='size-3.5' />
{t('Back')}
</Button>
<ModelHeader model={model} />
<PriceSection
model={model}
priceRate={priceRate ?? 1}
usdExchangeRate={usdExchangeRate ?? 1}
tokenUnit={tokenUnit}
showRechargePrice={search.rechargePrice ?? false}
groupRatio={groupRatio || {}}
/>
<EndpointsSection
model={model}
endpointMap={
(endpointMap as Record<
string,
{ path?: string; method?: string }
>) || {}
}
/>
{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,0 +1,274 @@
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>
)
})
@@ -0,0 +1,330 @@
import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import {
formatPrice,
formatRequestPrice,
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
// ----------------------------------------------------------------------------
// Pricing Table Columns
// ----------------------------------------------------------------------------
export interface PricingColumnsOptions {
tokenUnit?: TokenUnit
priceRate?: number
usdExchangeRate?: number
showRechargePrice?: boolean
}
function renderLimitedTags(
items: string[],
maxDisplay: number = 3
): React.ReactNode {
if (items.length === 0)
return <span className='text-muted-foreground/50 text-xs'></span>
const displayed = items.slice(0, maxDisplay)
const remaining = items.length - maxDisplay
return (
<span className='text-muted-foreground text-xs'>
{displayed.join(', ')}
{remaining > 0 && (
<span className='text-muted-foreground/50'> +{remaining}</span>
)}
</span>
)
}
export function usePricingColumns(
options: PricingColumnsOptions = {}
): ColumnDef<PricingModel>[] {
const { t } = useTranslation()
const {
tokenUnit = DEFAULT_TOKEN_UNIT,
priceRate = 1,
usdExchangeRate = 1,
showRechargePrice = false,
} = options
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
return [
// Model column
{
accessorKey: 'model_name',
meta: { label: t('Model') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: ({ row }) => {
const model = row.original
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 14)
: null
return (
<div className='flex min-w-[200px] items-center gap-2'>
{vendorIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
</span>
</div>
)
},
minSize: 200,
},
// Type column
{
accessorKey: 'quota_type',
meta: { label: t('Type') },
header: t('Type'),
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{isTokenBased ? t('Token') : t('Request')}
</span>
)
},
size: 80,
enableSorting: false,
},
// Price column
{
accessorKey: 'price',
meta: { label: t('Price') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} />
),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
const inputPrice = stripTrailingZeros(
formatPrice(
model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[160px]'>
<span className='font-mono text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
{outputPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel} tokens
</div>
</div>
)
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[100px]'>
<span className='font-mono text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {t('request')}
</div>
</div>
)
},
size: 180,
enableSorting: false,
},
// Cached price column (Vercel AI Gateway style)
{
id: 'cached_price',
meta: { label: t('Cached') },
header: t('Cached'),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased || model.cache_ratio == null) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const cachedPrice = stripTrailingZeros(
formatPrice(
model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[80px]'>
<span className='font-mono text-sm tabular-nums'>
{cachedPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel}
</div>
</div>
)
},
size: 110,
enableSorting: false,
},
// Vendor column
{
accessorKey: 'vendor_name',
meta: { label: t('Vendor') },
header: t('Vendor'),
cell: ({ row }) => {
const model = row.original
if (!model.vendor_name) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 12)
: null
return (
<span className='text-muted-foreground flex items-center gap-1.5 text-xs'>
{vendorIcon}
{model.vendor_name}
</span>
)
},
size: 130,
enableSorting: false,
},
// Tags column
{
accessorKey: 'tags',
meta: { label: t('Tags') },
header: t('Tags'),
cell: ({ row }) => {
const tags = parseTags(row.original.tags)
if (tags.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(tags, 2)}</div>
</TooltipTrigger>
{tags.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{tags.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 140,
enableSorting: false,
},
// Endpoints column
{
accessorKey: 'supported_endpoint_types',
meta: { label: t('Endpoints') },
header: t('Endpoints'),
cell: ({ row }) => {
const endpoints = row.original.supported_endpoint_types || []
if (endpoints.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(endpoints, 2)}</div>
</TooltipTrigger>
{endpoints.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{endpoints.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 130,
enableSorting: false,
},
// Enable Groups column
{
accessorKey: 'enable_groups',
meta: { label: t('Groups') },
header: t('Groups'),
cell: ({ row }) => {
const groups = row.original.enable_groups || []
if (groups.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(groups, 2)}</div>
</TooltipTrigger>
{groups.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{groups.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 130,
enableSorting: false,
},
]
}
@@ -0,0 +1,138 @@
import { useState, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type PaginationState,
} from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { TableSkeleton, TableEmpty } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import { usePricingColumns } from './pricing-columns'
export interface PricingTableProps {
models: PricingModel[]
isLoading?: boolean
priceRate?: number
usdExchangeRate?: number
tokenUnit?: TokenUnit
showRechargePrice?: boolean
}
export function PricingTable(props: PricingTableProps) {
const { t } = useTranslation()
const navigate = useNavigate({ from: '/pricing/' })
const {
models,
isLoading = false,
priceRate = 1,
usdExchangeRate = 1,
tokenUnit = DEFAULT_TOKEN_UNIT,
showRechargePrice = false,
} = props
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PRICING_PAGE_SIZE,
})
const columns = usePricingColumns({
tokenUnit,
priceRate,
usdExchangeRate,
showRechargePrice,
})
const table = useReactTable({
data: models,
columns,
pageCount: Math.ceil(models.length / pagination.pageSize),
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: false,
})
const handleRowClick = useCallback(
(model: PricingModel) => {
navigate({
to: '/pricing/$modelId',
params: { modelId: model.model_name },
search: (prev) => prev,
})
},
[navigate]
)
return (
<div className='space-y-4'>
<div className='overflow-hidden rounded-lg border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='pricing-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Models Found')}
description={t('No models match your current filters.')}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => handleRowClick(row.original)}
className='hover:bg-muted/30 cursor-pointer transition-colors'
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!isLoading && models.length > 0 && <DataTablePagination table={table} />}
</div>
)
}
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import { Search, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
export interface SearchBarProps {
value: string
onChange: (value: string) => void
onClear: () => void
placeholder?: string
className?: string
}
export function SearchBar(props: SearchBarProps) {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current?.blur()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div className={cn('relative', props.className)}>
<Search className='text-muted-foreground/60 pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2' />
<input
ref={inputRef}
type='text'
placeholder={props.placeholder || t('Search models...')}
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',
'h-10 w-full rounded-lg border pr-16 pl-10 text-sm transition-all outline-none'
)}
aria-label={t('Search models')}
/>
<div className='absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1'>
{props.value ? (
<Button
variant='ghost'
size='icon'
onClick={props.onClear}
className='text-muted-foreground/60 hover:text-foreground size-7'
aria-label={t('Clear search')}
>
<X className='size-4' />
</Button>
) : (
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded border px-1.5 py-0.5 font-mono text-[10px] sm:inline-block'>
K
</kbd>
)}
</div>
</div>
)
}
@@ -0,0 +1,70 @@
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>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { type TFunction } from 'i18next'
import type { TokenUnit } from './types'
// ----------------------------------------------------------------------------
// Pricing Constants
// ----------------------------------------------------------------------------
/** Sort options for pricing models */
export const SORT_OPTIONS = {
NAME: 'name',
PRICE_LOW: 'price-low',
PRICE_HIGH: 'price-high',
} as const
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS]
export function getSortLabels(t: TFunction): Record<SortOption, string> {
return {
[SORT_OPTIONS.NAME]: t('Name'),
[SORT_OPTIONS.PRICE_LOW]: t('Price: Low to High'),
[SORT_OPTIONS.PRICE_HIGH]: t('Price: High to Low'),
}
}
/** Filter values */
export const FILTER_ALL = 'all'
/** Quota type options */
export const QUOTA_TYPES = {
ALL: 'all',
TOKEN: 'token',
REQUEST: 'request',
} as const
export type QuotaTypeOption = (typeof QUOTA_TYPES)[keyof typeof QUOTA_TYPES]
/** Quota type labels */
export function getQuotaTypeLabels(
t: TFunction
): Record<QuotaTypeOption, string> {
return {
[QUOTA_TYPES.ALL]: t('All Models'),
[QUOTA_TYPES.TOKEN]: t('Token-based'),
[QUOTA_TYPES.REQUEST]: t('Per Request'),
}
}
/** Endpoint type options */
export const ENDPOINT_TYPES = {
ALL: 'all',
OPENAI: 'openai',
OPENAI_RESPONSE: 'openai-response',
ANTHROPIC: 'anthropic',
GEMINI: 'gemini',
JINA_RERANK: 'jina-rerank',
IMAGE_GENERATION: 'image-generation',
EMBEDDINGS: 'embeddings',
OPENAI_VIDEO: 'openai-video',
} as const
export type EndpointTypeOption =
(typeof ENDPOINT_TYPES)[keyof typeof ENDPOINT_TYPES]
/** Endpoint type labels */
export function getEndpointTypeLabels(
t: TFunction
): Record<EndpointTypeOption, string> {
return {
[ENDPOINT_TYPES.ALL]: t('All Types'),
[ENDPOINT_TYPES.OPENAI]: 'Chat',
[ENDPOINT_TYPES.OPENAI_RESPONSE]: 'Response',
[ENDPOINT_TYPES.ANTHROPIC]: 'Anthropic',
[ENDPOINT_TYPES.GEMINI]: 'Gemini',
[ENDPOINT_TYPES.JINA_RERANK]: 'Rerank',
[ENDPOINT_TYPES.IMAGE_GENERATION]: t('Image'),
[ENDPOINT_TYPES.EMBEDDINGS]: t('Embeddings'),
[ENDPOINT_TYPES.OPENAI_VIDEO]: t('Video'),
}
}
/** Filter section keys */
export const FILTER_SECTIONS = {
PRICING_TYPE: 'pricingType',
ENDPOINT_TYPE: 'endpointType',
VENDOR: 'vendor',
GROUP: 'group',
TAG: 'tag',
} as const
/** Maximum number of tags to display in model row */
export const MAX_TAGS_DISPLAY = 5
/** Maximum number of filter items to display before showing "More..." */
export const MAX_FILTER_ITEMS = 5
/** Sidebar width */
export const SIDEBAR_WIDTH = 'w-64'
/** Excluded groups */
export const EXCLUDED_GROUPS = ['', 'auto']
/** Quota type values */
export const QUOTA_TYPE_VALUES = {
TOKEN: 0,
REQUEST: 1,
} as const
/** Token unit divisors */
export const TOKEN_UNIT_DIVISORS = {
M: 1,
K: 1000,
} as const
/** Default token unit for pricing display */
export const DEFAULT_TOKEN_UNIT: TokenUnit = 'M'
/** View mode options */
export const VIEW_MODES = {
LIST: 'list',
TABLE: 'table',
} as const
export type ViewMode = (typeof VIEW_MODES)[keyof typeof VIEW_MODES]
/** Default page size for pricing table */
export const DEFAULT_PRICING_PAGE_SIZE = 20
+2
View File
@@ -0,0 +1,2 @@
export { useFilters } from './use-filters'
export { usePricingData } from './use-pricing-data'
+189
View File
@@ -0,0 +1,189 @@
import { useMemo, useCallback } from 'react'
import { useSearch, useNavigate } from '@tanstack/react-router'
import {
FILTER_ALL,
SORT_OPTIONS,
QUOTA_TYPES,
ENDPOINT_TYPES,
DEFAULT_TOKEN_UNIT,
VIEW_MODES,
type ViewMode,
} from '../constants'
import { filterAndSortModels, extractAllTags } from '../lib/filters'
import type { PricingModel, TokenUnit } from '../types'
export function useFilters(models: PricingModel[]) {
const search = useSearch({ from: '/pricing/' })
const navigate = useNavigate({ from: '/pricing/' })
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 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
const updateSearch = 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]
}
}
return next
},
replace: true,
})
},
[navigate]
)
const setSearchInput = useCallback(
(v: string) => updateSearch({ search: v || undefined }),
[updateSearch]
)
const setSortBy = useCallback(
(v: string) =>
updateSearch({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
[updateSearch]
)
const setVendorFilter = useCallback(
(v: string) => updateSearch({ vendor: v === FILTER_ALL ? undefined : v }),
[updateSearch]
)
const setGroupFilter = useCallback(
(v: string) => updateSearch({ group: v === FILTER_ALL ? undefined : v }),
[updateSearch]
)
const setQuotaTypeFilter = useCallback(
(v: string) =>
updateSearch({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
[updateSearch]
)
const setEndpointTypeFilter = useCallback(
(v: string) =>
updateSearch({
endpointType: v === ENDPOINT_TYPES.ALL ? undefined : v,
}),
[updateSearch]
)
const setTagFilter = useCallback(
(v: string) => updateSearch({ tag: v === FILTER_ALL ? undefined : v }),
[updateSearch]
)
const setTokenUnit = useCallback(
(v: TokenUnit) =>
updateSearch({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
[updateSearch]
)
const setViewMode = useCallback(
(v: ViewMode) =>
updateSearch({ view: v === VIEW_MODES.LIST ? undefined : v }),
[updateSearch]
)
const setShowRechargePrice = useCallback(
(v: boolean) => updateSearch({ rechargePrice: v || undefined }),
[updateSearch]
)
const availableTags = useMemo(() => {
if (!models || models.length === 0) return []
return extractAllTags(models)
}, [models])
const filteredModels = useMemo(() => {
if (!models || models.length === 0) return []
return filterAndSortModels(models, {
search: searchInput,
vendor: vendorFilter,
group: groupFilter,
quotaType: quotaTypeFilter,
endpointType: endpointTypeFilter,
tag: tagFilter,
sortBy,
})
}, [
models,
searchInput,
vendorFilter,
groupFilter,
quotaTypeFilter,
endpointTypeFilter,
tagFilter,
sortBy,
])
const hasActiveFilters = useMemo(
() =>
vendorFilter !== FILTER_ALL ||
groupFilter !== FILTER_ALL ||
quotaTypeFilter !== QUOTA_TYPES.ALL ||
endpointTypeFilter !== ENDPOINT_TYPES.ALL ||
tagFilter !== FILTER_ALL,
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter]
)
const activeFilterCount = useMemo(
() =>
(vendorFilter !== FILTER_ALL ? 1 : 0) +
(groupFilter !== FILTER_ALL ? 1 : 0) +
(quotaTypeFilter !== QUOTA_TYPES.ALL ? 1 : 0) +
(endpointTypeFilter !== ENDPOINT_TYPES.ALL ? 1 : 0) +
(tagFilter !== FILTER_ALL ? 1 : 0),
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter]
)
const clearFilters = useCallback(() => {
updateSearch({
vendor: undefined,
group: undefined,
quotaType: undefined,
endpointType: undefined,
tag: undefined,
})
}, [updateSearch])
const clearSearch = useCallback(() => {
updateSearch({ search: undefined })
}, [updateSearch])
return {
searchInput,
sortBy,
vendorFilter,
groupFilter,
quotaTypeFilter,
endpointTypeFilter,
tagFilter,
tokenUnit,
viewMode,
showRechargePrice,
setSearchInput,
setSortBy,
setVendorFilter,
setGroupFilter,
setQuotaTypeFilter,
setEndpointTypeFilter,
setTagFilter,
setTokenUnit,
setViewMode,
setShowRechargePrice,
filteredModels,
hasActiveFilters,
activeFilterCount,
availableTags,
clearFilters,
clearSearch,
}
}
@@ -0,0 +1,58 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useStatus } from '@/hooks/use-status'
import { getPricing } from '../api'
export function usePricingData() {
const { status } = useStatus()
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['pricing'],
queryFn: getPricing,
staleTime: 5 * 60 * 1000,
})
// Ensure rates never reach zero to prevent division errors
const priceRate = useMemo(
() => Math.max((status?.price as number) ?? 1, 0.001),
[status?.price]
)
const usdExchangeRate = useMemo(
() => Math.max((status?.usd_exchange_rate as number) ?? priceRate, 0.001),
[status?.usd_exchange_rate, priceRate]
)
const models = useMemo(() => {
if (!data?.data || !data?.vendors) return []
const vendorMap = new Map(data.vendors.map((v) => [v.id, v]))
return data.data.map((model) => {
const vendor = model.vendor_id
? vendorMap.get(model.vendor_id)
: undefined
return {
...model,
key: model.model_name,
vendor_name: vendor?.name,
vendor_icon: vendor?.icon,
vendor_description: vendor?.description,
group_ratio: data.group_ratio,
}
})
}, [data])
return {
models,
vendors: data?.vendors ?? [],
groupRatio: data?.group_ratio ?? {},
usableGroup: data?.usable_group ?? {},
endpointMap: data?.supported_endpoint ?? {},
autoGroups: data?.auto_groups ?? [],
isLoading,
error,
refetch,
priceRate,
usdExchangeRate,
}
}
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import {
LoadingSkeleton,
EmptyState,
SearchBar,
FilterBar,
VirtualModelList,
PricingTable,
} from './components'
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
import { useFilters } from './hooks/use-filters'
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 {
models,
vendors,
usableGroup,
isLoading,
priceRate,
usdExchangeRate,
} = usePricingData()
const {
searchInput,
sortBy,
vendorFilter,
groupFilter,
quotaTypeFilter,
endpointTypeFilter,
tagFilter,
tokenUnit,
viewMode,
showRechargePrice,
setSearchInput,
setSortBy,
setVendorFilter,
setGroupFilter,
setQuotaTypeFilter,
setEndpointTypeFilter,
setTagFilter,
setTokenUnit,
setViewMode,
setShowRechargePrice,
filteredModels,
hasActiveFilters,
activeFilterCount,
availableTags,
clearFilters,
clearSearch,
} = useFilters(models || [])
const handleModelClick = useCallback(
(modelName: string) => {
navigate({
to: '/pricing/$modelId',
params: { modelId: modelName },
search: (prev) => prev,
})
},
[navigate]
)
const availableGroups = useMemo(
() =>
Object.keys(usableGroup || {}).filter(
(g) => !EXCLUDED_GROUPS.includes(g)
),
[usableGroup]
)
const handleClearAll = useCallback(() => {
clearFilters()
clearSearch()
}, [clearFilters, clearSearch])
if (isLoading) {
return (
<PublicLayout>
<div className='mx-auto max-w-6xl px-4 sm:px-6'>
<LoadingSkeleton viewMode={viewMode} />
</div>
</PublicLayout>
)
}
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}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
)
) : (
<EmptyState
searchQuery={searchInput}
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearAll}
/>
)}
</div>
</PageTransition>
</PublicLayout>
)
}
+752
View File
@@ -0,0 +1,752 @@
/**
* Billing expression parsing utilities.
*
* Mirrors the parser used by the classic frontend so that the dynamic
* pricing breakdown UI can be rendered from the same backend expressions.
*
* The grammar is intentionally narrow: we only support the shapes that the
* server emits (tiered pricing + request-rule conditional multipliers), so
* the regular expressions are exact rather than tolerant of arbitrary
* expression syntax.
*/
// ---------------------------------------------------------------------------
// Variable registry
// ---------------------------------------------------------------------------
export type BillingVar = {
key: string
field: string | null
tierField: string | null
label: string
shortLabel: string
side: 'input' | 'output' | 'condition'
isBase?: boolean
isConditionOnly?: boolean
group?: string
}
export const BILLING_VARS: BillingVar[] = [
{
key: 'p',
field: 'inputPrice',
tierField: 'input_unit_cost',
label: 'Input price',
shortLabel: 'Input',
side: 'input',
isBase: true,
},
{
key: 'c',
field: 'outputPrice',
tierField: 'output_unit_cost',
label: 'Completion price',
shortLabel: 'Output',
side: 'output',
isBase: true,
},
{
key: 'len',
field: null,
tierField: null,
label: 'Input length',
shortLabel: 'Length',
side: 'condition',
isConditionOnly: true,
},
{
key: 'cr',
field: 'cacheReadPrice',
tierField: 'cache_read_unit_cost',
label: 'Cache read price',
shortLabel: 'Cache Read',
side: 'input',
group: 'cache',
},
{
key: 'cc',
field: 'cacheCreatePrice',
tierField: 'cache_create_unit_cost',
label: 'Cache create price',
shortLabel: 'Cache Write',
side: 'input',
group: 'cache',
},
{
key: 'cc1h',
field: 'cacheCreate1hPrice',
tierField: 'cache_create_1h_unit_cost',
label: 'Cache create (1h) price',
shortLabel: 'Cache Write (1h)',
side: 'input',
group: 'cache',
},
{
key: 'img',
field: 'imagePrice',
tierField: 'image_unit_cost',
label: 'Image input price',
shortLabel: 'Image In',
side: 'input',
group: 'media',
},
{
key: 'img_o',
field: 'imageOutputPrice',
tierField: 'image_output_unit_cost',
label: 'Image output price',
shortLabel: 'Image Out',
side: 'output',
group: 'media',
},
{
key: 'ai',
field: 'audioInputPrice',
tierField: 'audio_input_unit_cost',
label: 'Audio input price',
shortLabel: 'Audio In',
side: 'input',
group: 'media',
},
{
key: 'ao',
field: 'audioOutputPrice',
tierField: 'audio_output_unit_cost',
label: 'Audio output price',
shortLabel: 'Audio Out',
side: 'output',
group: 'media',
},
]
/** Vars that have real price fields (excludes condition-only vars like `len`) */
export const BILLING_PRICING_VARS: BillingVar[] = BILLING_VARS.filter(
(v) => !v.isConditionOnly
)
/** Vars valid in tier conditions (`p`, `c`, `len`) */
export const BILLING_CONDITION_VARS: string[] = BILLING_VARS.filter(
(v) => v.isBase || v.isConditionOnly
).map((v) => v.key)
const BILLING_VAR_KEY_TO_FIELD = Object.fromEntries(
BILLING_PRICING_VARS.map((v) => [v.key, v.field as string])
) as Record<string, string>
export const BILLING_EXTRA_VARS: BillingVar[] = BILLING_VARS.filter(
(v) => !v.isBase && !v.isConditionOnly
)
export const BILLING_CACHE_VAR_MAP = BILLING_EXTRA_VARS.map((v) => ({
field: v.tierField as string,
exprVar: v.key,
}))
const BILLING_VAR_REGEX = new RegExp(
`\\b(${BILLING_PRICING_VARS.map((v) => v.key).join('|')})\\s*\\*\\s*([\\d.eE+-]+)`,
'g'
)
// ---------------------------------------------------------------------------
// Request rule constants
// ---------------------------------------------------------------------------
export const SOURCE_PARAM = 'param'
export const SOURCE_HEADER = 'header'
export const SOURCE_TIME = 'time'
export const MATCH_EQ = 'eq'
export const MATCH_CONTAINS = 'contains'
export const MATCH_GT = 'gt'
export const MATCH_GTE = 'gte'
export const MATCH_LT = 'lt'
export const MATCH_LTE = 'lte'
export const MATCH_EXISTS = 'exists'
export const MATCH_RANGE = 'range'
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'] as const
export type TimeFunc = (typeof TIME_FUNCS)[number]
export const COMMON_TIMEZONES: { value: string; label: string }[] = [
{ value: 'Asia/Shanghai', label: 'UTC+8 Shanghai (Asia/Shanghai)' },
{ value: 'UTC', label: 'UTC' },
{ value: 'America/New_York', label: 'UTC-5 New York (America/New_York)' },
{
value: 'America/Los_Angeles',
label: 'UTC-8 Los Angeles (America/Los_Angeles)',
},
{ value: 'America/Chicago', label: 'UTC-6 Chicago (America/Chicago)' },
{ value: 'Europe/London', label: 'UTC+0 London (Europe/London)' },
{ value: 'Europe/Berlin', label: 'UTC+1 Berlin (Europe/Berlin)' },
{ value: 'Asia/Tokyo', label: 'UTC+9 Tokyo (Asia/Tokyo)' },
{ value: 'Asia/Singapore', label: 'UTC+8 Singapore (Asia/Singapore)' },
{ value: 'Asia/Seoul', label: 'UTC+9 Seoul (Asia/Seoul)' },
{ value: 'Australia/Sydney', label: 'UTC+10 Sydney (Australia/Sydney)' },
]
const NUMERIC_LITERAL_REGEX = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/
export type ParamHeaderCondition = {
source: 'param' | 'header'
path: string
mode: string
value: string
}
export type TimeCondition = {
source: 'time'
timeFunc: TimeFunc
timezone: string
mode: string
value: string
rangeStart: string
rangeEnd: string
}
export type RequestCondition = TimeCondition | ParamHeaderCondition
export type RequestRuleGroup = {
conditions: RequestCondition[]
multiplier: string
}
export type TierCondition = {
var: 'p' | 'c' | 'len'
op: '<' | '<=' | '>' | '>='
value: number
}
export type ParsedTier = {
label: string
conditions: TierCondition[]
[field: string]: unknown
}
// ---------------------------------------------------------------------------
// Tier parser
// ---------------------------------------------------------------------------
function stripExprVersion(exprStr: string): { version: number; body: string } {
if (!exprStr) return { version: 1, body: '' }
const m = exprStr.match(/^v(\d+):([\s\S]*)$/)
if (m) return { version: Number(m[1]), body: m[2] }
return { version: 1, body: exprStr }
}
function parseTierBody(bodyStr: string): Record<string, number> {
const coeffs: Record<string, number> = {}
const re = new RegExp(BILLING_VAR_REGEX.source, 'g')
let m
while ((m = re.exec(bodyStr)) !== null) {
if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2])
}
const tier: Record<string, number> = {}
for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) {
tier[field] = coeffs[varName] || 0
}
return tier
}
export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
if (!exprStr) return []
try {
const { body } = stripExprVersion(exprStr)
const condGroup =
`((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` +
`(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
const tierRe = new RegExp(
`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`,
'g'
)
const tiers: ParsedTier[] = []
let m
while ((m = tierRe.exec(body)) !== null) {
const condStr = m[1] || ''
const conditions: TierCondition[] = []
if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
if (cm) {
conditions.push({
var: cm[1] as TierCondition['var'],
op: cm[2] as TierCondition['op'],
value: Number(cm[3]),
})
}
}
}
const tier = parseTierBody(m[3]) as ParsedTier
tier.label = m[2]
tier.conditions = conditions
tiers.push(tier)
}
return tiers
} catch {
return []
}
}
// ---------------------------------------------------------------------------
// Request rule parser
// ---------------------------------------------------------------------------
function splitTopLevelMultiply(expr: string): string[] {
const parts: string[] = []
let start = 0
let depth = 0
for (let index = 0; index < expr.length; index += 1) {
const char = expr[index]
if (char === '(') depth += 1
if (char === ')') depth -= 1
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
parts.push(expr.slice(start, index).trim())
start = index + 3
index += 2
}
}
parts.push(expr.slice(start).trim())
return parts.filter(Boolean)
}
function splitTopLevelAnd(expr: string): string[] {
const parts: string[] = []
let start = 0
let depth = 0
for (let i = 0; i < expr.length; i += 1) {
const c = expr[i]
if (c === '(') depth += 1
if (c === ')') depth -= 1
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
parts.push(expr.slice(start, i).trim())
start = i + 4
i += 3
}
}
parts.push(expr.slice(start).trim())
return parts.filter(Boolean)
}
function parseExprLiteral(raw: string): string | null {
const text = raw.trim()
if (text === 'true' || text === 'false') return text
if (NUMERIC_LITERAL_REGEX.test(text)) return text
try {
return JSON.parse(text) as string
} catch {
return null
}
}
function tryParseTimeCondition(expr: string): RequestCondition | null {
let m = expr.match(
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/
)
if (m) {
return {
source: 'time',
timeFunc: m[1] as TimeFunc,
timezone: m[2],
mode: MATCH_RANGE,
value: '',
rangeStart: m[3],
rangeEnd: m[4],
}
}
m = expr.match(
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/
)
if (m) {
return {
source: 'time',
timeFunc: m[1] as TimeFunc,
timezone: m[2],
mode: MATCH_RANGE,
value: '',
rangeStart: m[3],
rangeEnd: m[4],
}
}
m = expr.match(
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/
)
if (m) {
const opMap: Record<string, string> = {
'==': MATCH_EQ,
'>=': MATCH_GTE,
'<': MATCH_LT,
}
return {
source: 'time',
timeFunc: m[1] as TimeFunc,
timezone: m[2],
mode: opMap[m[3]] || MATCH_EQ,
value: m[4],
rangeStart: '',
rangeEnd: '',
}
}
return null
}
function tryParseRequestCondition(expr: string): RequestCondition | null {
const tc = tryParseTimeCondition(expr)
if (tc) return tc
let m = expr.match(/^header\("([^"]+)"\) != ""$/)
if (m) return { source: 'header', path: m[1], mode: MATCH_EXISTS, value: '' }
m = expr.match(/^param\("([^"]+)"\) != nil$/)
if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' }
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/)
if (m)
return {
source: 'header',
path: m[1],
mode: MATCH_CONTAINS,
value: JSON.parse(m[2]) as string,
}
m = expr.match(
/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/
)
if (m && m[1] === m[2])
return {
source: 'param',
path: m[1],
mode: MATCH_CONTAINS,
value: JSON.parse(m[3]) as string,
}
m = expr.match(
/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/
)
if (m && m[1] === m[2]) {
const opMap: Record<string, string> = {
'>': MATCH_GT,
'>=': MATCH_GTE,
'<': MATCH_LT,
'<=': MATCH_LTE,
}
return { source: 'param', path: m[1], mode: opMap[m[3]], value: m[4] }
}
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/)
if (m) {
const parsedValue = parseExprLiteral(m[3])
if (parsedValue === null) return null
return {
source: m[1] as 'param' | 'header',
path: m[2],
mode: MATCH_EQ,
value: String(parsedValue),
}
}
return null
}
function tryParseRuleGroupFactor(part: string): RequestRuleGroup | null {
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s)
if (!m) return null
const conditionStr = m[1]
const multiplier = m[2]
const andParts = splitTopLevelAnd(conditionStr)
const conditions: RequestCondition[] = []
for (const ap of andParts) {
const cond = tryParseRequestCondition(ap.trim())
if (!cond) return null
conditions.push(cond)
}
if (conditions.length === 0) return null
return { conditions, multiplier }
}
export function tryParseRequestRuleExpr(
expr: string
): RequestRuleGroup[] | null {
const trimmed = (expr || '').trim()
if (!trimmed) return []
const parts = splitTopLevelMultiply(trimmed)
const groups: RequestRuleGroup[] = []
for (const part of parts) {
const group = tryParseRuleGroupFactor(part)
if (!group) return null
groups.push(group)
}
return groups
}
// ---------------------------------------------------------------------------
// Combine / split billing expr and request rules
// ---------------------------------------------------------------------------
function hasFullOuterParens(expr: string): boolean {
if (!expr.startsWith('(') || !expr.endsWith(')')) return false
let depth = 0
for (let i = 0; i < expr.length; i += 1) {
if (expr[i] === '(') depth += 1
if (expr[i] === ')') depth -= 1
if (depth === 0 && i < expr.length - 1) return false
}
return depth === 0
}
function unwrapOuterParens(expr: string): string {
let current = (expr || '').trim()
while (hasFullOuterParens(current)) {
current = current.slice(1, -1).trim()
}
return current
}
export function splitBillingExprAndRequestRules(expr: string): {
billingExpr: string
requestRuleExpr: string
} {
const trimmed = (expr || '').trim()
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' }
const parts = splitTopLevelMultiply(trimmed)
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' }
const ruleParts: string[] = []
const baseParts: string[] = []
parts.forEach((part) => {
const parsed = tryParseRequestRuleExpr(part)
if (parsed && parsed.length > 0) {
ruleParts.push(part)
} else {
baseParts.push(part)
}
})
if (ruleParts.length === 0 || baseParts.length !== 1) {
return { billingExpr: trimmed, requestRuleExpr: '' }
}
return {
billingExpr: unwrapOuterParens(baseParts[0]),
requestRuleExpr: ruleParts.join(' * '),
}
}
export function combineBillingExpr(
baseExpr: string,
requestRuleExpr: string
): string {
const base = (baseExpr || '').trim()
const rules = (requestRuleExpr || '').trim()
if (!base) return ''
if (!rules) return base
return `(${base}) * ${rules}`
}
// ---------------------------------------------------------------------------
// Editor: empty constructors
// ---------------------------------------------------------------------------
export function createEmptyCondition(): ParamHeaderCondition {
return { source: 'param', path: '', mode: MATCH_EQ, value: '' }
}
export function createEmptyTimeCondition(): TimeCondition {
return {
source: 'time',
timeFunc: 'hour',
timezone: 'Asia/Shanghai',
mode: MATCH_GTE,
value: '',
rangeStart: '',
rangeEnd: '',
}
}
export function createEmptyRuleGroup(): RequestRuleGroup {
return { conditions: [createEmptyCondition()], multiplier: '' }
}
export function createEmptyTimeRuleGroup(): RequestRuleGroup {
return { conditions: [createEmptyTimeCondition()], multiplier: '' }
}
// ---------------------------------------------------------------------------
// Editor: match option helpers
// ---------------------------------------------------------------------------
export type MatchOption = { value: string; labelKey: string }
export function getRequestRuleMatchOptions(source: string): MatchOption[] {
if (source === SOURCE_TIME) {
return [
{ value: MATCH_EQ, labelKey: 'Equals' },
{ value: MATCH_GTE, labelKey: 'Greater than or equal' },
{ value: MATCH_LT, labelKey: 'Less than' },
{ value: MATCH_RANGE, labelKey: 'Overnight range' },
]
}
const base: MatchOption[] = [
{ value: MATCH_EQ, labelKey: 'Equals' },
{ value: MATCH_CONTAINS, labelKey: 'Contains' },
{ value: MATCH_EXISTS, labelKey: 'Exists' },
]
if (source === SOURCE_HEADER) return base
return [
...base,
{ value: MATCH_GT, labelKey: 'Greater than' },
{ value: MATCH_GTE, labelKey: 'Greater than or equal' },
{ value: MATCH_LT, labelKey: 'Less than' },
{ value: MATCH_LTE, labelKey: 'Less than or equal' },
]
}
// ---------------------------------------------------------------------------
// Editor: normalize a single condition
// ---------------------------------------------------------------------------
function isTimeFunc(value: unknown): value is TimeFunc {
return typeof value === 'string' && TIME_FUNCS.includes(value as TimeFunc)
}
export function normalizeCondition(
cond: Partial<RequestCondition> | null | undefined
): RequestCondition {
const source =
cond?.source === 'time'
? 'time'
: cond?.source === 'header'
? 'header'
: 'param'
if (source === 'time') {
const timeCond = cond as Partial<TimeCondition> | null | undefined
const timeFunc: TimeFunc = isTimeFunc(timeCond?.timeFunc)
? timeCond.timeFunc
: 'hour'
const options = getRequestRuleMatchOptions(SOURCE_TIME)
const mode = options.some((item) => item.value === timeCond?.mode)
? (timeCond?.mode as string)
: MATCH_GTE
return {
source: 'time',
timeFunc,
timezone: timeCond?.timezone || 'Asia/Shanghai',
mode,
value: timeCond?.value == null ? '' : String(timeCond.value),
rangeStart:
timeCond?.rangeStart == null ? '' : String(timeCond.rangeStart),
rangeEnd: timeCond?.rangeEnd == null ? '' : String(timeCond.rangeEnd),
}
}
const phCond = cond as Partial<ParamHeaderCondition> | null | undefined
const options = getRequestRuleMatchOptions(source)
const mode = options.some((item) => item.value === phCond?.mode)
? (phCond?.mode as string)
: MATCH_EQ
return {
source,
path: phCond?.path || '',
mode,
value: phCond?.value == null ? '' : String(phCond.value),
}
}
// ---------------------------------------------------------------------------
// Editor: build expression strings
// ---------------------------------------------------------------------------
function buildExprLiteral(mode: string, value: string): string {
const text = String(value || '').trim()
if (mode === MATCH_CONTAINS) return JSON.stringify(text)
if (text === 'true' || text === 'false') return text
if (NUMERIC_LITERAL_REGEX.test(text)) return text
return JSON.stringify(text)
}
function buildTimeConditionExpr(cond: TimeCondition): string {
const normalized = normalizeCondition(cond) as TimeCondition
const { timeFunc, timezone, mode } = normalized
const tz = JSON.stringify(timezone)
const fn = `${timeFunc}(${tz})`
if (mode === MATCH_RANGE) {
const s = normalized.rangeStart.trim()
const e = normalized.rangeEnd.trim()
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) {
return ''
}
return `${fn} >= ${s} || ${fn} < ${e}`
}
const v = normalized.value.trim()
if (!NUMERIC_LITERAL_REGEX.test(v)) return ''
const opMap: Record<string, string> = {
[MATCH_EQ]: '==',
[MATCH_GTE]: '>=',
[MATCH_LT]: '<',
}
return `${fn} ${opMap[mode] || '=='} ${v}`
}
function buildRequestConditionExpr(cond: RequestCondition): string {
if (cond.source === 'time') return buildTimeConditionExpr(cond)
const normalized = normalizeCondition(cond) as ParamHeaderCondition
const path = normalized.path.trim()
if (!path) return ''
const sourceExpr =
normalized.source === 'header'
? `header(${JSON.stringify(path)})`
: `param(${JSON.stringify(path)})`
switch (normalized.mode) {
case MATCH_EXISTS:
return normalized.source === 'header'
? `${sourceExpr} != ""`
: `${sourceExpr} != nil`
case MATCH_CONTAINS:
return normalized.source === 'header'
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
case MATCH_GT:
case MATCH_GTE:
case MATCH_LT:
case MATCH_LTE: {
const opMap: Record<string, string> = {
[MATCH_GT]: '>',
[MATCH_GTE]: '>=',
[MATCH_LT]: '<',
[MATCH_LTE]: '<=',
}
const numText = String(normalized.value).trim()
if (!NUMERIC_LITERAL_REGEX.test(numText)) return ''
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${numText}`
}
case MATCH_EQ:
default:
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`
}
}
function buildRuleGroupFactor(group: RequestRuleGroup): string {
const multiplier = (group.multiplier || '').trim()
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return ''
const condExprs = (group.conditions || [])
.map(buildRequestConditionExpr)
.filter(Boolean)
if (condExprs.length === 0) return ''
const combined =
condExprs.length === 1
? condExprs[0]
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ')
return `(${combined} ? ${multiplier} : 1)`
}
export function buildRequestRuleExpr(groups: RequestRuleGroup[]): string {
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ')
}
+186
View File
@@ -0,0 +1,186 @@
import {
SORT_OPTIONS,
FILTER_ALL,
QUOTA_TYPES,
QUOTA_TYPE_VALUES,
ENDPOINT_TYPES,
} from '../constants'
import type { PricingModel } from '../types'
// ----------------------------------------------------------------------------
// Filter Utilities
// ----------------------------------------------------------------------------
/**
* Filter models by search query
*/
export function filterBySearch(
models: PricingModel[],
query: string
): PricingModel[] {
if (!query) return models
const lowerQuery = query.toLowerCase()
return models.filter(
(m) =>
m.model_name?.toLowerCase().includes(lowerQuery) ||
m.description?.toLowerCase().includes(lowerQuery) ||
m.tags?.toLowerCase().includes(lowerQuery) ||
m.vendor_name?.toLowerCase().includes(lowerQuery)
)
}
/**
* Filter models by vendor
*/
export function filterByVendor(
models: PricingModel[],
vendor: string
): PricingModel[] {
if (vendor === FILTER_ALL) return models
return models.filter((m) => m.vendor_name === vendor)
}
/**
* Filter models by group
*/
export function filterByGroup(
models: PricingModel[],
group: string
): PricingModel[] {
if (group === FILTER_ALL) return models
return models.filter((m) => m.enable_groups?.includes(group))
}
/**
* Filter models by quota type
*/
export function filterByQuotaType(
models: PricingModel[],
quotaType: string
): PricingModel[] {
if (quotaType === QUOTA_TYPES.ALL) return models
const targetType =
quotaType === QUOTA_TYPES.TOKEN
? QUOTA_TYPE_VALUES.TOKEN
: QUOTA_TYPE_VALUES.REQUEST
return models.filter((m) => m.quota_type === targetType)
}
/**
* Filter models by endpoint type
*/
export function filterByEndpointType(
models: PricingModel[],
endpointType: string
): PricingModel[] {
if (endpointType === ENDPOINT_TYPES.ALL) return models
return models.filter((m) =>
m.supported_endpoint_types?.includes(endpointType)
)
}
/**
* Get model price for sorting
*/
function getModelPrice(model: PricingModel): number {
return model.quota_type === 0 ? model.model_ratio : model.model_price || 0
}
/**
* Sort models by specified option
*/
export function sortModels(
models: PricingModel[],
sortBy: string
): PricingModel[] {
const sorted = [...models]
switch (sortBy) {
case SORT_OPTIONS.NAME:
sorted.sort((a, b) =>
(a.model_name || '').localeCompare(b.model_name || '')
)
break
case SORT_OPTIONS.PRICE_LOW:
sorted.sort((a, b) => getModelPrice(a) - getModelPrice(b))
break
case SORT_OPTIONS.PRICE_HIGH:
sorted.sort((a, b) => getModelPrice(b) - getModelPrice(a))
break
}
return sorted
}
/**
* Apply all filters and sorting to models
*/
export function filterAndSortModels(
models: PricingModel[],
filters: {
search: string
vendor: string
group: string
quotaType: string
endpointType: string
tag: string
sortBy: string
}
): PricingModel[] {
let result = filterBySearch(models, filters.search)
result = filterByVendor(result, filters.vendor)
result = filterByGroup(result, filters.group)
result = filterByQuotaType(result, filters.quotaType)
result = filterByEndpointType(result, filters.endpointType)
result = filterByTag(result, filters.tag)
result = sortModels(result, filters.sortBy)
return result
}
/**
* Parse tags from comma-separated string
*/
export function parseTags(tagsString?: string): string[] {
if (!tagsString) return []
return tagsString
.split(/[,;|\s]+/)
.map((t) => t.trim())
.filter(Boolean)
}
/**
* Extract all unique tags from models
*/
export function extractAllTags(models: PricingModel[]): string[] {
const tagSet = new Set<string>()
models.forEach((model) => {
if (model.tags) {
const tags = parseTags(model.tags)
tags.forEach((tag) => {
tagSet.add(tag.toLowerCase())
})
}
})
return Array.from(tagSet).sort((a, b) => a.localeCompare(b))
}
/**
* Filter models by tag
*/
export function filterByTag(
models: PricingModel[],
tag: string
): PricingModel[] {
if (tag === FILTER_ALL) return models
const tagLower = tag.toLowerCase()
return models.filter((m) => {
if (!m.tags) return false
const modelTags = parseTags(m.tags).map((t) => t.toLowerCase())
return modelTags.includes(tagLower)
})
}
+9
View File
@@ -0,0 +1,9 @@
// ----------------------------------------------------------------------------
// Pricing Lib Exports
// ----------------------------------------------------------------------------
export * from './filters'
export * from './price'
export * from './model-helpers'
export * from './billing-expr'
export * from './tier-expr'
+36
View File
@@ -0,0 +1,36 @@
import { EXCLUDED_GROUPS, QUOTA_TYPE_VALUES } from '../constants'
import type { PricingModel } from '../types'
// ----------------------------------------------------------------------------
// Model Helper Utilities
// ----------------------------------------------------------------------------
/**
* Get available groups for a model
*/
export function getAvailableGroups(
model: PricingModel,
usableGroup: Record<string, { desc: string; ratio: number }>
): string[] {
const modelEnableGroups = Array.isArray(model.enable_groups)
? model.enable_groups
: []
return Object.keys(usableGroup)
.filter((g) => !EXCLUDED_GROUPS.includes(g))
.filter((g) => modelEnableGroups.includes(g))
}
/**
* Replace model placeholder in endpoint path
*/
export function replaceModelInPath(path: string, modelName: string): string {
return path.replace(/\{model\}/g, modelName)
}
/**
* Check if model is token-based pricing
*/
export function isTokenBasedModel(model: PricingModel): boolean {
return model.quota_type === QUOTA_TYPE_VALUES.TOKEN
}
+279
View File
@@ -0,0 +1,279 @@
import { formatCurrencyFromUSD } from '@/lib/currency'
import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants'
import type { PricingModel, TokenUnit, PriceType } from '../types'
// ----------------------------------------------------------------------------
// Price Calculation Utilities
// ----------------------------------------------------------------------------
/**
* Strip trailing zeros from formatted price string while preserving currency symbols
*/
export function stripTrailingZeros(formatted: string): string {
// Match currency symbol at start, number, and potential 'k' suffix
const match = formatted.match(/^([^\d-]*)([-\d,]+\.?\d*)(k?)$/)
if (!match) return formatted
const [, symbol, number, suffix] = match
// Remove commas for processing
const cleanNumber = number.replace(/,/g, '')
// Convert to number and back to remove trailing zeros
const parsed = parseFloat(cleanNumber)
if (isNaN(parsed)) return formatted
// Convert to string, which automatically removes trailing zeros
let result = parsed.toString()
// If the result is in scientific notation, format it properly
if (result.includes('e')) {
result = parsed.toFixed(20).replace(/\.?0+$/, '')
}
return `${symbol}${result}${suffix}`
}
/**
* Find minimum group ratio from enabled groups
*/
function getMinGroupRatio(
enableGroups: string[],
groupRatio: Record<string, number>
): number {
if (enableGroups.length === 0) return 1
let minRatio = Number.POSITIVE_INFINITY
for (const group of enableGroups) {
const ratio = groupRatio[group]
if (ratio !== undefined && ratio < minRatio) {
minRatio = ratio
}
}
return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio
}
/**
* Calculate token price in USD.
*
* Returns NaN when the required ratio field is missing/null so callers can
* skip rendering that price type.
*/
function calculateTokenPrice(
model: PricingModel,
type: PriceType,
ratio: number
): number {
const base = model.model_ratio * 2 * ratio
switch (type) {
case 'input':
return base
case 'output':
return base * model.completion_ratio
case 'cache':
return hasRatio(model.cache_ratio)
? base * Number(model.cache_ratio)
: NaN
case 'create_cache':
return hasRatio(model.create_cache_ratio)
? base * Number(model.create_cache_ratio)
: NaN
case 'image':
return hasRatio(model.image_ratio)
? base * Number(model.image_ratio)
: NaN
case 'audio_input':
return hasRatio(model.audio_ratio)
? base * Number(model.audio_ratio)
: NaN
case 'audio_output':
return hasRatio(model.audio_ratio) &&
hasRatio(model.audio_completion_ratio)
? base *
Number(model.audio_ratio) *
Number(model.audio_completion_ratio)
: NaN
}
}
function hasRatio(value: number | null | undefined): boolean {
return value !== undefined && value !== null && Number.isFinite(Number(value))
}
/**
* Apply recharge rate to price
*
* priceRate represents how much users need to recharge (in the display currency)
* to get 1 USD credit. usdExchangeRate is the real exchange rate.
*
* The returned value will be formatted by formatCurrencyFromUSD, which will
* multiply by the display currency's exchange rate.
*
* Examples:
*
* 1. Display currency = USD:
* - Model: 1 USD
* - priceRate = 0.5 (recharge $0.5 to get $1 credit)
* - usdExchangeRate = 1
* - Return: 1 × 0.5 / 1 = 0.5
* - formatCurrencyFromUSD(0.5) → $0.5 ✓
*
* 2. Display currency = CNY:
* - Model: 1 USD
* - priceRate = 4 (recharge ¥4 to get $1 credit)
* - usdExchangeRate = 7 (real rate: 1 USD = ¥7)
* - Return: 1 × 4 / 7 = 0.571
* - formatCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓
* - Normal price: ¥7, Recharge price: ¥4 (cheaper!)
*/
function applyRechargeRate(
price: number,
showWithRecharge: boolean,
priceRate: number,
usdExchangeRate: number
): number {
if (!showWithRecharge) return price
return (price * priceRate) / usdExchangeRate
}
/**
* Format token-based price for display
*/
export function formatPrice(
model: PricingModel,
type: PriceType,
tokenUnit: TokenUnit,
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1
): string {
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
return '-'
}
const enableGroups = Array.isArray(model.enable_groups)
? model.enable_groups
: []
const groupRatio = model.group_ratio || {}
const minRatio = getMinGroupRatio(enableGroups, groupRatio)
let priceInUSD = calculateTokenPrice(model, type, minRatio)
priceInUSD = applyRechargeRate(
priceInUSD,
showWithRecharge,
priceRate,
usdExchangeRate
)
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, {
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
})
}
/**
* Format price for a specific group (token-based)
*/
export function formatGroupPrice(
model: PricingModel,
group: string,
type: PriceType,
tokenUnit: TokenUnit,
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1,
groupRatio: Record<string, number>
): string {
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
return '-'
}
const ratio = groupRatio[group] || 1
let priceInUSD = calculateTokenPrice(model, type, ratio)
priceInUSD = applyRechargeRate(
priceInUSD,
showWithRecharge,
priceRate,
usdExchangeRate
)
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
return formatCurrencyFromUSD(price, {
digitsLarge: 4,
digitsSmall: 6,
abbreviate: false,
})
}
/**
* Format fixed price for pay-per-request models (with specific group)
*/
export function formatFixedPrice(
model: PricingModel,
group: string,
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1,
groupRatio: Record<string, number>
): string {
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
return '-'
}
const ratio = groupRatio[group] || 1
let priceInUSD = (model.model_price || 0) * ratio
priceInUSD = applyRechargeRate(
priceInUSD,
showWithRecharge,
priceRate,
usdExchangeRate
)
return formatCurrencyFromUSD(priceInUSD, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
})
}
/**
* Format fixed price for pay-per-request models (minimum price from all groups)
*/
export function formatRequestPrice(
model: PricingModel,
showWithRecharge = false,
priceRate = 1,
usdExchangeRate = 1
): string {
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
return '-'
}
const enableGroups = Array.isArray(model.enable_groups)
? model.enable_groups
: []
const groupRatio = model.group_ratio || {}
const minRatio = getMinGroupRatio(enableGroups, groupRatio)
let priceInUSD = (model.model_price || 0) * minRatio
priceInUSD = applyRechargeRate(
priceInUSD,
showWithRecharge,
priceRate,
usdExchangeRate
)
return formatCurrencyFromUSD(priceInUSD, {
digitsLarge: 4,
digitsSmall: 4,
abbreviate: false,
})
}
+305
View File
@@ -0,0 +1,305 @@
import { BILLING_CACHE_VAR_MAP } from './billing-expr'
export const CACHE_MODE_TIMED = 'timed'
export const CACHE_MODE_GENERIC = 'generic'
export type CacheMode = typeof CACHE_MODE_TIMED | typeof CACHE_MODE_GENERIC
export type TierConditionInput = {
var: 'p' | 'c' | 'len'
op: '<' | '<=' | '>' | '>='
value: number | string
}
export type VisualTier = {
label: string
conditions: TierConditionInput[]
input_unit_cost: number
output_unit_cost: number
cache_mode: CacheMode
cache_read_unit_cost?: number
cache_create_unit_cost?: number
cache_create_1h_unit_cost?: number
image_unit_cost?: number
image_output_unit_cost?: number
audio_input_unit_cost?: number
audio_output_unit_cost?: number
[field: string]: unknown
}
export type VisualConfig = {
tiers: VisualTier[]
}
export function getTierCacheMode(
tier: Partial<VisualTier> | null | undefined
): CacheMode {
if (tier?.cache_mode === CACHE_MODE_TIMED) return CACHE_MODE_TIMED
if (tier?.cache_mode === CACHE_MODE_GENERIC) return CACHE_MODE_GENERIC
return Number(tier?.cache_create_1h_unit_cost) > 0
? CACHE_MODE_TIMED
: CACHE_MODE_GENERIC
}
export function normalizeVisualTier(
tier: Partial<VisualTier> = {}
): VisualTier {
return {
label: tier.label ?? '',
input_unit_cost: Number(tier.input_unit_cost) || 0,
output_unit_cost: Number(tier.output_unit_cost) || 0,
cache_mode: getTierCacheMode(tier),
conditions: Array.isArray(tier.conditions) ? tier.conditions : [],
...tier,
cache_read_unit_cost: Number(tier.cache_read_unit_cost) || 0,
cache_create_unit_cost: Number(tier.cache_create_unit_cost) || 0,
cache_create_1h_unit_cost: Number(tier.cache_create_1h_unit_cost) || 0,
image_unit_cost: Number(tier.image_unit_cost) || 0,
image_output_unit_cost: Number(tier.image_output_unit_cost) || 0,
audio_input_unit_cost: Number(tier.audio_input_unit_cost) || 0,
audio_output_unit_cost: Number(tier.audio_output_unit_cost) || 0,
}
}
export function createDefaultVisualConfig(): VisualConfig {
return {
tiers: [
normalizeVisualTier({
conditions: [],
input_unit_cost: 0,
output_unit_cost: 0,
label: 'base',
cache_mode: CACHE_MODE_GENERIC,
}),
],
}
}
export function normalizeVisualConfig(
config: VisualConfig | null | undefined
): VisualConfig {
if (!config || !Array.isArray(config.tiers) || config.tiers.length === 0) {
return createDefaultVisualConfig()
}
return {
...config,
tiers: config.tiers.map((tier) => normalizeVisualTier(tier)),
}
}
function buildConditionStr(conditions: TierConditionInput[]): string {
if (!conditions || conditions.length === 0) return ''
return conditions
.filter((c) => c.var && c.op && c.value != null && c.value !== '')
.map((c) => `${c.var} ${c.op} ${c.value}`)
.join(' && ')
}
function buildTierBodyExpr(tier: VisualTier): string {
const parts: string[] = []
const ic = Number(tier.input_unit_cost) || 0
const oc = Number(tier.output_unit_cost) || 0
parts.push(`p * ${ic}`)
parts.push(`c * ${oc}`)
for (const cv of BILLING_CACHE_VAR_MAP) {
const v = Number((tier as Record<string, unknown>)[cv.field]) || 0
if (v !== 0) parts.push(`${cv.exprVar} * ${v}`)
}
return parts.join(' + ')
}
export function generateExprFromVisualConfig(
config: VisualConfig | null | undefined
): string {
if (!config || !config.tiers || config.tiers.length === 0) {
return 'p * 0 + c * 0'
}
const tiers = config.tiers
if (tiers.length === 1) {
const tier = tiers[0]
const label = tier.label || 'default'
const body = `tier("${label}", ${buildTierBodyExpr(tier)})`
const cond = buildConditionStr(tier.conditions)
if (cond) {
return `${cond} ? ${body} : p * 0 + c * 0`
}
return body
}
const parts: string[] = []
for (let i = 0; i < tiers.length; i++) {
const tier = tiers[i]
const label = tier.label || `tier_${i + 1}`
const body = `tier("${label}", ${buildTierBodyExpr(tier)})`
const cond = buildConditionStr(tier.conditions)
if (i < tiers.length - 1 && cond) {
parts.push(`${cond} ? ${body}`)
} else {
parts.push(body)
}
}
return parts.join(' : ')
}
export function tryParseVisualConfig(
exprStr: string | null | undefined
): VisualConfig | null {
if (!exprStr) return null
try {
let body = exprStr
const versionMatch = body.match(/^v\d+:([\s\S]*)$/)
if (versionMatch) body = versionMatch[1]
const cacheVarNames = BILLING_CACHE_VAR_MAP.map((cv) => cv.exprVar)
const optCacheStr = cacheVarNames
.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`)
.join('')
const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCacheStr}`
const singleRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`)
const simple = body.match(singleRe)
if (simple) {
const tier: Record<string, unknown> = {
conditions: [],
input_unit_cost: Number(simple[2]),
output_unit_cost: Number(simple[3]),
label: simple[1],
}
BILLING_CACHE_VAR_MAP.forEach((cv, i) => {
const val = simple[4 + i]
if (val != null) tier[cv.field] = Number(val)
})
return normalizeVisualConfig({
tiers: [normalizeVisualTier(tier as Partial<VisualTier>)],
})
}
const condGroup =
`((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` +
`(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
const tierRe = new RegExp(
`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`,
'g'
)
const tiers: VisualTier[] = []
let match: RegExpExecArray | null
while ((match = tierRe.exec(body)) !== null) {
const condStr = match[1] || ''
const conditions: TierConditionInput[] = []
if (condStr) {
for (const cp of condStr.split(/\s*&&\s*/)) {
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
if (cm) {
conditions.push({
var: cm[1] as TierConditionInput['var'],
op: cm[2] as TierConditionInput['op'],
value: Number(cm[3]),
})
}
}
}
const tier: Record<string, unknown> = {
conditions,
input_unit_cost: Number(match[3]),
output_unit_cost: Number(match[4]),
label: match[2],
}
const m = match
BILLING_CACHE_VAR_MAP.forEach((cv, i) => {
const val = m[5 + i]
if (val != null) tier[cv.field] = Number(val)
})
tiers.push(normalizeVisualTier(tier as Partial<VisualTier>))
}
if (tiers.length === 0) return null
const cfg = normalizeVisualConfig({ tiers })
const regenerated = generateExprFromVisualConfig(cfg)
if (regenerated.replace(/\s+/g, '') !== body.replace(/\s+/g, '')) {
return null
}
return cfg
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Local cost evaluator (for the estimator preview)
// ---------------------------------------------------------------------------
const ESTIMATOR_VARS = [
{ var: 'cr', stateKey: 'cacheReadTokens' },
{ var: 'cc', stateKey: 'cacheCreateTokens' },
{ var: 'cc1h', stateKey: 'cacheCreate1hTokens' },
{ var: 'img', stateKey: 'imageTokens' },
{ var: 'img_o', stateKey: 'imageOutputTokens' },
{ var: 'ai', stateKey: 'audioInputTokens' },
{ var: 'ao', stateKey: 'audioOutputTokens' },
] as const
export type ExtraTokenValues = Record<
(typeof ESTIMATOR_VARS)[number]['stateKey'],
number
>
export type EvalResult = {
cost: number
matchedTier: string
error: string | null
}
export function evalExprLocally(
exprStr: string,
promptTokens: number,
completionTokens: number,
extraTokenValues: ExtraTokenValues
): EvalResult {
try {
if (!exprStr || !exprStr.trim()) {
return { cost: 0, matchedTier: '', error: null }
}
let matchedTier = ''
const tierFn = (name: string, value: number) => {
matchedTier = name
return value
}
const cacheReadTokens = extraTokenValues.cacheReadTokens || 0
const cacheCreateTokens = extraTokenValues.cacheCreateTokens || 0
const cacheCreate1hTokens = extraTokenValues.cacheCreate1hTokens || 0
const len =
promptTokens + cacheReadTokens + cacheCreateTokens + cacheCreate1hTokens
const env: Record<string, unknown> = {
p: promptTokens,
c: completionTokens,
len,
tier: tierFn,
max: Math.max,
min: Math.min,
abs: Math.abs,
ceil: Math.ceil,
floor: Math.floor,
}
for (const field of ESTIMATOR_VARS) {
env[field.var] = extraTokenValues[field.stateKey] || 0
}
const fn = new Function(
...Object.keys(env),
`"use strict"; return (${exprStr});`
)
const cost = Number(fn(...Object.values(env))) || 0
return { cost, matchedTier, error: null }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return { cost: 0, matchedTier: '', error: message }
}
}
export function exprUsesExtraVars(exprStr: string): boolean {
if (!exprStr) return false
const varNames = ESTIMATOR_VARS.map((f) => f.var).join('|')
return new RegExp(`\\b(${varNames})\\b`).test(exprStr)
}
export const ESTIMATOR_EXTRA_FIELDS = ESTIMATOR_VARS
+62
View File
@@ -0,0 +1,62 @@
// ----------------------------------------------------------------------------
// Pricing Types
// ----------------------------------------------------------------------------
export type PricingVendor = {
id: number
name: string
icon?: string
description?: string
}
export type PricingModel = {
id: number
model_name: string
description?: string
vendor_id?: number
vendor_name?: string
vendor_icon?: string
vendor_description?: string
quota_type: number
model_ratio: number
completion_ratio: number
model_price?: number
cache_ratio?: number | null
create_cache_ratio?: number | null
image_ratio?: number | null
audio_ratio?: number | null
audio_completion_ratio?: number | null
enable_groups: string[]
tags?: string
supported_endpoint_types?: string[]
key?: string
group_ratio?: Record<string, number>
/** Billing mode (e.g. "tiered_expr") used to flag dynamic pricing */
billing_mode?: string
/** Raw expression describing dynamic / tiered billing */
billing_expr?: string
/** Pricing version returned by backend, useful for cache busting */
pricing_version?: string
}
export type PricingData = {
success: boolean
message?: string
data: PricingModel[]
vendors: PricingVendor[]
group_ratio: Record<string, number>
usable_group: Record<string, { desc: string; ratio: number }>
supported_endpoint: Record<string, string>
auto_groups: string[]
}
export type TokenUnit = 'M' | 'K'
export type PriceType =
| 'input'
| 'output'
| 'cache'
| 'create_cache'
| 'image'
| 'audio_input'
| 'audio_output'
export type QuotaType = 0 | 1 // 0: token-based, 1: per-request