🚀 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
co-authored by Seefs Seefs feitianbubu Calcium-Ion Xyfacai xiangsx 郑伯涛 RedwindA dean QuentinHsu Bliod Apple\Apple <zeraturing@foxmail.com>
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table'
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { StatusBadge } from '@/components/status-badge'
import type { UpstreamChannel } from '../types'
import {
CHANNEL_STATUS_CONFIG,
DEFAULT_ENDPOINT,
ENDPOINT_OPTIONS,
MODELS_DEV_PRESET_ID,
OFFICIAL_CHANNEL_ID,
} from './constants'
type ChannelSelectorDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
channels: UpstreamChannel[]
selectedChannelIds: number[]
onSelectedChannelIdsChange: (ids: number[]) => void
channelEndpoints: Record<number, string>
onChannelEndpointsChange: (endpoints: Record<number, string>) => void
onConfirm: (selectedIds: number[]) => void
}
// Synthesized presets from `controller/ratio_sync.go` always carry stable
// negative IDs, so matching by ID alone is reliable and self-documenting.
function isOfficialChannel(channel: UpstreamChannel): boolean {
return (
channel.id === OFFICIAL_CHANNEL_ID || channel.id === MODELS_DEV_PRESET_ID
)
}
export function ChannelSelectorDialog({
open,
onOpenChange,
channels,
selectedChannelIds,
onSelectedChannelIdsChange,
channelEndpoints,
onChannelEndpointsChange,
onConfirm,
}: ChannelSelectorDialogProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
useEffect(() => {
if (!selectedChannelIds.length) {
setRowSelection({})
return
}
const availableChannelIds = new Set(channels.map((channel) => channel.id))
const newSelection: RowSelectionState = {}
selectedChannelIds.forEach((id) => {
if (availableChannelIds.has(id)) {
newSelection[id.toString()] = true
}
})
setRowSelection(newSelection)
}, [selectedChannelIds, channels])
const updateEndpoint = useCallback(
(channelId: number, endpoint: string) => {
onChannelEndpointsChange({
...channelEndpoints,
[channelId]: endpoint,
})
},
[channelEndpoints, onChannelEndpointsChange]
)
const getEndpointType = (endpoint: string) => {
const option = ENDPOINT_OPTIONS.find((opt) => opt.value === endpoint)
return option ? endpoint : 'custom'
}
const columns = useMemo<ColumnDef<UpstreamChannel>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'name',
header: t('Name'),
cell: ({ row }) => {
const name = row.getValue('name') as string
const channel = row.original
const isOfficial = isOfficialChannel(channel)
return (
<div className='flex items-center gap-2'>
<span className='font-medium'>{name}</span>
{isOfficial && (
<StatusBadge
label={t('Official')}
variant='success'
size='sm'
copyable={false}
/>
)}
</div>
)
},
},
{
accessorKey: 'base_url',
header: t('Base URL'),
cell: ({ row }) => {
const url = row.getValue('base_url') as string
return (
<span
className='text-muted-foreground block max-w-xs truncate font-mono text-xs'
title={url}
>
{url}
</span>
)
},
},
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const status = row.getValue('status') as number
const config =
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG]
if (!config) {
return (
<StatusBadge
label={t('Unknown')}
variant='neutral'
size='sm'
copyable={false}
/>
)
}
return (
<StatusBadge
label={config.label}
variant={config.variant}
size='sm'
copyable={false}
/>
)
},
},
{
id: 'endpoint',
header: t('Sync Endpoint'),
cell: ({ row }) => {
const channel = row.original
const currentEndpoint =
channelEndpoints[channel.id] || DEFAULT_ENDPOINT
const endpointType = getEndpointType(currentEndpoint)
const handleTypeChange = (value: string) => {
if (value === 'custom') {
updateEndpoint(channel.id, '')
} else {
updateEndpoint(channel.id, value)
}
}
return (
<div className='flex items-center gap-2'>
<Select value={endpointType} onValueChange={handleTypeChange}>
<SelectTrigger className='h-8 w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENDPOINT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{endpointType === 'custom' && (
<Input
value={currentEndpoint}
onChange={(e) => updateEndpoint(channel.id, e.target.value)}
placeholder={t('/your/endpoint')}
className='h-8 w-40 font-mono text-xs'
/>
)}
</div>
)
},
},
],
[channelEndpoints, t, updateEndpoint]
)
const filteredChannels = useMemo(() => {
if (!search.trim()) return channels
const searchLower = search.toLowerCase()
return channels.filter(
(ch) =>
ch.name.toLowerCase().includes(searchLower) ||
ch.base_url.toLowerCase().includes(searchLower)
)
}, [channels, search])
const sortedChannels = useMemo(() => {
return [...filteredChannels].sort((a, b) => {
const aIsOfficial = isOfficialChannel(a)
const bIsOfficial = isOfficialChannel(b)
if (aIsOfficial && !bIsOfficial) return -1
if (!aIsOfficial && bIsOfficial) return 1
return 0
})
}, [filteredChannels])
const table = useReactTable({
data: sortedChannels,
columns,
state: {
rowSelection,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 10,
},
},
})
const handleConfirm = () => {
const selectedRows = table.getSelectedRowModel().rows
const selectedIds = selectedRows.map((row) => row.original.id)
onSelectedChannelIdsChange(selectedIds)
onOpenChange(false)
onConfirm(selectedIds)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'>
<DialogHeader>
<DialogTitle>{t('Select Sync Channels')}</DialogTitle>
<DialogDescription>
{t('Choose channels to sync upstream ratio configurations from')}
</DialogDescription>
</DialogHeader>
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by name or URL...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='ps-8'
/>
</div>
</div>
<div className='flex-1 overflow-auto rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm}>{t('Confirm Selection')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,261 @@
import { useEffect, useRef } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
formatJsonForTextarea,
normalizeJsonString,
validateJsonString,
} from './utils'
const schema = z.object({
claude: z.object({
model_headers_settings: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
default_max_tokens: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
thinking_adapter_enabled: z.boolean(),
thinking_adapter_budget_tokens_percentage: z.coerce
.number()
.min(0.1, { message: 'Must be at least 0.1' })
.max(1, { message: 'Must be 1 or less' }),
}),
})
type ClaudeSettingsFormValues = z.output<typeof schema>
type ClaudeSettingsFormInput = z.input<typeof schema>
type FlatClaudeSettings = {
'claude.model_headers_settings': string
'claude.default_max_tokens': string
'claude.thinking_adapter_enabled': boolean
'claude.thinking_adapter_budget_tokens_percentage': number
}
type ClaudeSettingsCardProps = {
defaultValues: ClaudeSettingsFormInput
}
export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const normalizedDefaultsRef = useRef<FlatClaudeSettings>({
'claude.model_headers_settings': normalizeJsonString(
defaultValues.claude.model_headers_settings
),
'claude.default_max_tokens': normalizeJsonString(
defaultValues.claude.default_max_tokens
),
'claude.thinking_adapter_enabled':
defaultValues.claude.thinking_adapter_enabled,
'claude.thinking_adapter_budget_tokens_percentage': Number(
defaultValues.claude.thinking_adapter_budget_tokens_percentage
),
})
const buildFormDefaults = (
values: ClaudeSettingsFormInput
): ClaudeSettingsFormInput => ({
claude: {
model_headers_settings: formatJsonForTextarea(
values.claude.model_headers_settings
),
default_max_tokens: formatJsonForTextarea(
values.claude.default_max_tokens
),
thinking_adapter_enabled: values.claude.thinking_adapter_enabled,
thinking_adapter_budget_tokens_percentage:
values.claude.thinking_adapter_budget_tokens_percentage,
},
})
const form = useForm<
ClaudeSettingsFormInput,
unknown,
ClaudeSettingsFormValues
>({
resolver: zodResolver(schema),
defaultValues: buildFormDefaults(defaultValues),
})
useEffect(() => {
normalizedDefaultsRef.current = {
'claude.model_headers_settings': normalizeJsonString(
defaultValues.claude.model_headers_settings
),
'claude.default_max_tokens': normalizeJsonString(
defaultValues.claude.default_max_tokens
),
'claude.thinking_adapter_enabled':
defaultValues.claude.thinking_adapter_enabled,
'claude.thinking_adapter_budget_tokens_percentage': Number(
defaultValues.claude.thinking_adapter_budget_tokens_percentage
),
}
form.reset(buildFormDefaults(defaultValues))
}, [defaultValues, form])
const onSubmit = async (values: ClaudeSettingsFormValues) => {
const normalized: FlatClaudeSettings = {
'claude.model_headers_settings': normalizeJsonString(
values.claude.model_headers_settings
),
'claude.default_max_tokens': normalizeJsonString(
values.claude.default_max_tokens
),
'claude.thinking_adapter_enabled': values.claude.thinking_adapter_enabled,
'claude.thinking_adapter_budget_tokens_percentage':
values.claude.thinking_adapter_budget_tokens_percentage,
}
const updates = (
Object.keys(normalized) as Array<keyof FlatClaudeSettings>
).filter((key) => normalized[key] !== normalizedDefaultsRef.current[key])
if (updates.length === 0) {
toast.info(t('No changes to save'))
return
}
for (const key of updates) {
await updateOption.mutateAsync({ key, value: normalized[key] })
}
}
return (
<SettingsSection
title={t('Claude')}
description={t(
'Override Anthropic headers, defaults, and thinking adapter behavior'
)}
>
<Form {...form}>
{/* eslint-disable-next-line react-hooks/refs */}
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='claude.model_headers_settings'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Request Header Overrides')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='claude.default_max_tokens'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Default Max Tokens')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Example')}{' '}
{`{ "default": 8192, "claude-3-haiku-20240307": 4096, "claude-3-opus-20240229": 4096, "claude-3-7-sonnet-20250219-thinking": 8192 }`}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-4 rounded-lg border p-4'>
<FormField
control={form.control}
name='claude.thinking_adapter_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Thinking Adapter')}
</FormLabel>
<FormDescription>
{t(
'Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='claude.thinking_adapter_budget_tokens_percentage'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Budget Tokens Ratio')}</FormLabel>
<FormControl>
<Input
{...field}
value={String(field.value ?? '')}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Budget tokens = max tokens × ratio. Accepts a decimal between 0.1 and 1.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,102 @@
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
export type ConflictItem = {
channel: string
model: string
current: string
newVal: string
}
type ConflictConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
conflicts: ConflictItem[]
onConfirm: () => void
isLoading?: boolean
}
export function ConflictConfirmDialog({
open,
onOpenChange,
conflicts,
onConfirm,
isLoading = false,
}: ConflictConfirmDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-4xl'>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm Billing Conflicts')}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='max-h-96 overflow-y-auto rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Channel')}</TableHead>
<TableHead>{t('Model')}</TableHead>
<TableHead>{t('Current Billing')}</TableHead>
<TableHead>{t('Change To')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conflicts.map((conflict, index) => (
<TableRow key={index}>
<TableCell className='font-medium'>
{conflict.channel}
</TableCell>
<TableCell className='font-mono text-sm'>
{conflict.model}
</TableCell>
<TableCell>
<pre className='text-xs whitespace-pre-wrap'>
{conflict.current}
</pre>
</TableCell>
<TableCell>
<pre className='text-xs whitespace-pre-wrap'>
{conflict.newVal}
</pre>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={isLoading}>
{isLoading ? t('Applying...') : t('Confirm Changes')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,59 @@
export const DEFAULT_ENDPOINT = '/api/pricing'
// ---------------------------------------------------------------------------
// Built-in upstream ratio presets
//
// The backend (`controller/ratio_sync.go`) synthesizes two virtual channels and
// returns them in the syncable channels response. The constants below mirror
// the backend literals one-to-one; do NOT translate the *_NAME values because
// they are wire-protocol identifiers, not user-facing labels.
//
// Identification on the frontend should rely on the stable negative ID alone.
// `*_NAME` and `*_BASE_URL` are kept for diagnostics, custom channel
// detection, and backwards compatibility with the classic frontend.
// ---------------------------------------------------------------------------
export const OFFICIAL_CHANNEL_ID = -100
export const OFFICIAL_CHANNEL_NAME = '官方倍率预设'
export const OFFICIAL_CHANNEL_BASE_URL = 'https://basellm.github.io'
export const OFFICIAL_CHANNEL_ENDPOINT =
'/llm-metadata/api/newapi/ratio_config-v1-base.json'
export const MODELS_DEV_PRESET_ID = -101
export const MODELS_DEV_PRESET_NAME = 'models.dev 价格预设'
export const MODELS_DEV_PRESET_BASE_URL = 'https://models.dev'
export const MODELS_DEV_PRESET_ENDPOINT = 'https://models.dev/api.json'
export const OPENROUTER_ENDPOINT = 'openrouter'
// Backend channel type for OpenRouter (see constant/channel.go: ChannelTypeOpenRouter = 20)
export const OPENROUTER_CHANNEL_TYPE = 20
export const ENDPOINT_OPTIONS = [
{ label: 'pricing', value: '/api/pricing' },
{ label: 'ratio_config', value: '/api/ratio_config' },
{ label: 'OpenRouter', value: OPENROUTER_ENDPOINT },
{ label: 'custom', value: 'custom' },
] as const
// Labels reuse the existing sentence-case i18n keys defined for form fields
// (e.g. `Model ratio`, `Audio completion ratio`). Do NOT switch to Title Case
// here without updating the i18n catalog; otherwise we end up with two keys per
// ratio type that only differ in capitalization.
export const RATIO_TYPE_OPTIONS = [
{ label: 'Model ratio', value: 'model_ratio' },
{ label: 'Completion ratio', value: 'completion_ratio' },
{ label: 'Cache ratio', value: 'cache_ratio' },
{ label: 'Create cache ratio', value: 'create_cache_ratio' },
{ label: 'Image ratio', value: 'image_ratio' },
{ label: 'Audio ratio', value: 'audio_ratio' },
{ label: 'Audio completion ratio', value: 'audio_completion_ratio' },
{ label: 'Fixed price', value: 'model_price' },
{ label: 'Expression billing', value: 'billing_expr' },
] as const
export const CHANNEL_STATUS_CONFIG = {
1: { label: 'Enabled', variant: 'success' as const },
2: { label: 'Disabled', variant: 'danger' as const },
3: { label: 'Auto-Disabled', variant: 'warning' as const },
} as const
@@ -0,0 +1,397 @@
import { useEffect, useMemo, useRef } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
formatJsonForTextarea,
normalizeJsonString,
validateJsonString,
} from './utils'
const schema = z.object({
gemini: z.object({
safety_settings: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
version_settings: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
supported_imagine_models: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
predicate: (parsed) =>
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of model identifiers',
})
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON array',
})
}
}),
thinking_adapter_enabled: z.boolean(),
thinking_adapter_budget_tokens_percentage: z.coerce
.number()
.min(0.002, { message: 'Must be at least 0.002' })
.max(1, { message: 'Must be 1 or less' }),
function_call_thought_signature_enabled: z.boolean(),
remove_function_response_id_enabled: z.boolean(),
}),
})
type GeminiSettingsFormValues = z.output<typeof schema>
type GeminiSettingsFormInput = z.input<typeof schema>
type FlatGeminiSettings = {
'gemini.safety_settings': string
'gemini.version_settings': string
'gemini.supported_imagine_models': string
'gemini.thinking_adapter_enabled': boolean
'gemini.thinking_adapter_budget_tokens_percentage': number
'gemini.function_call_thought_signature_enabled': boolean
'gemini.remove_function_response_id_enabled': boolean
}
type GeminiSettingsCardProps = {
defaultValues: GeminiSettingsFormInput
}
export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const normalizedDefaultsRef = useRef<FlatGeminiSettings>({
'gemini.safety_settings': normalizeJsonString(
defaultValues.gemini.safety_settings
),
'gemini.version_settings': normalizeJsonString(
defaultValues.gemini.version_settings
),
'gemini.supported_imagine_models': normalizeJsonString(
defaultValues.gemini.supported_imagine_models
),
'gemini.thinking_adapter_enabled':
defaultValues.gemini.thinking_adapter_enabled,
'gemini.thinking_adapter_budget_tokens_percentage': Number(
defaultValues.gemini.thinking_adapter_budget_tokens_percentage
),
'gemini.function_call_thought_signature_enabled':
defaultValues.gemini.function_call_thought_signature_enabled ?? true,
'gemini.remove_function_response_id_enabled':
defaultValues.gemini.remove_function_response_id_enabled ?? true,
})
const buildFormDefaults = (
values: GeminiSettingsFormInput
): GeminiSettingsFormInput => ({
gemini: {
safety_settings: formatJsonForTextarea(values.gemini.safety_settings),
version_settings: formatJsonForTextarea(values.gemini.version_settings),
supported_imagine_models: formatJsonForTextarea(
values.gemini.supported_imagine_models
),
thinking_adapter_enabled: values.gemini.thinking_adapter_enabled,
thinking_adapter_budget_tokens_percentage:
values.gemini.thinking_adapter_budget_tokens_percentage,
function_call_thought_signature_enabled:
values.gemini.function_call_thought_signature_enabled ?? true,
remove_function_response_id_enabled:
values.gemini.remove_function_response_id_enabled ?? true,
},
})
const form = useForm<
GeminiSettingsFormInput,
unknown,
GeminiSettingsFormValues
>({
resolver: zodResolver(schema),
defaultValues: buildFormDefaults(defaultValues),
})
useEffect(() => {
normalizedDefaultsRef.current = {
'gemini.safety_settings': normalizeJsonString(
defaultValues.gemini.safety_settings
),
'gemini.version_settings': normalizeJsonString(
defaultValues.gemini.version_settings
),
'gemini.supported_imagine_models': normalizeJsonString(
defaultValues.gemini.supported_imagine_models
),
'gemini.thinking_adapter_enabled':
defaultValues.gemini.thinking_adapter_enabled,
'gemini.thinking_adapter_budget_tokens_percentage': Number(
defaultValues.gemini.thinking_adapter_budget_tokens_percentage
),
'gemini.function_call_thought_signature_enabled':
defaultValues.gemini.function_call_thought_signature_enabled ?? true,
'gemini.remove_function_response_id_enabled':
defaultValues.gemini.remove_function_response_id_enabled ?? true,
}
form.reset(buildFormDefaults(defaultValues))
}, [defaultValues, form])
const isAdapterEnabled = form.watch('gemini.thinking_adapter_enabled')
const onSubmit = async (values: GeminiSettingsFormValues) => {
const normalized: FlatGeminiSettings = {
'gemini.safety_settings': normalizeJsonString(
values.gemini.safety_settings
),
'gemini.version_settings': normalizeJsonString(
values.gemini.version_settings
),
'gemini.supported_imagine_models': normalizeJsonString(
values.gemini.supported_imagine_models
),
'gemini.thinking_adapter_enabled': values.gemini.thinking_adapter_enabled,
'gemini.thinking_adapter_budget_tokens_percentage':
values.gemini.thinking_adapter_budget_tokens_percentage,
'gemini.function_call_thought_signature_enabled':
values.gemini.function_call_thought_signature_enabled,
'gemini.remove_function_response_id_enabled':
values.gemini.remove_function_response_id_enabled,
}
const updates = (
Object.keys(normalized) as Array<keyof FlatGeminiSettings>
).filter((key) => normalized[key] !== normalizedDefaultsRef.current[key])
if (updates.length === 0) {
toast.info(t('No changes to save'))
return
}
for (const key of updates) {
await updateOption.mutateAsync({
key,
value: normalized[key],
})
}
}
const imaginePlaceholder = useMemo(
() => JSON.stringify(['gemini-2.0-flash-exp-image-generation'], null, 2),
[]
)
return (
<SettingsSection
title={t('Gemini')}
description={t(
'Configure Gemini safety behavior, version overrides, and thinking adapter'
)}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='gemini.safety_settings'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Safety Settings')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Provide per-category safety overrides as JSON. Use `default` for fallback values.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gemini.version_settings'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Version Overrides')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Map model identifiers to Gemini API versions. A `default` entry applies when no specific match is found.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gemini.supported_imagine_models'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Supported Imagine Models')}</FormLabel>
<FormControl>
<Textarea
rows={6}
placeholder={imaginePlaceholder}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Accepts a JSON array of model identifiers that support the Imagine API.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-4 rounded-lg border p-4'>
<FormField
control={form.control}
name='gemini.thinking_adapter_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Thinking Adapter')}
</FormLabel>
<FormDescription>
{t('Supports `-thinking`, `-thinking-')}
{'{{budget}}'}
{t(
'`, and `-nothinking` suffixes while routing to the correct Gemini variant.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='gemini.thinking_adapter_budget_tokens_percentage'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Budget Tokens Ratio')}</FormLabel>
<FormControl>
<Input
{...field}
value={String(field.value ?? '')}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Budget tokens = max tokens × ratio. Accepts a decimal between 0.002 and 1. Recommended to keep aligned with upstream billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isAdapterEnabled && (
<p className='text-muted-foreground text-sm'>
{t(
'Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.'
)}
</p>
)}
</div>
<FormField
control={form.control}
name='gemini.function_call_thought_signature_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable FunctionCall thoughtSignature Fill')}
</FormLabel>
<FormDescription>
{t(
'Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='gemini.remove_function_response_id_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Remove functionResponse.id field')}
</FormLabel>
<FormDescription>
{t(
'Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,395 @@
import { useEffect } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const thinkingBlacklistExample = JSON.stringify(
['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
null,
2
)
const chatToResponsesPolicyExample = JSON.stringify(
{
enabled: true,
all_channels: false,
channel_ids: [1, 2],
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
},
null,
2
)
const chatToResponsesPolicyAllChannelsExample = JSON.stringify(
{
enabled: true,
all_channels: true,
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
},
null,
2
)
const jsonString = z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
try {
JSON.parse(trimmed)
return true
} catch {
return false
}
}, 'Invalid JSON format')
const schema = z.object({
global: z.object({
pass_through_request_enabled: z.boolean(),
thinking_model_blacklist: jsonString,
chat_completions_to_responses_policy: jsonString,
}),
general_setting: z.object({
ping_interval_enabled: z.boolean(),
ping_interval_seconds: z.coerce.number().min(1),
}),
})
type GlobalModelSettingsFormValues = z.output<typeof schema>
type GlobalModelSettingsFormInput = z.input<typeof schema>
type FlatGlobalModelSettings = {
'global.pass_through_request_enabled': boolean
'global.thinking_model_blacklist': string
'global.chat_completions_to_responses_policy': string
'general_setting.ping_interval_enabled': boolean
'general_setting.ping_interval_seconds': number
}
const flattenGlobalValues = (
values: GlobalModelSettingsFormValues
): FlatGlobalModelSettings => ({
'global.pass_through_request_enabled':
values.global.pass_through_request_enabled,
'global.thinking_model_blacklist': normalizeJsonText(
values.global.thinking_model_blacklist,
'[]'
),
'global.chat_completions_to_responses_policy': normalizeJsonText(
values.global.chat_completions_to_responses_policy,
'{}'
),
'general_setting.ping_interval_enabled':
values.general_setting.ping_interval_enabled,
'general_setting.ping_interval_seconds':
values.general_setting.ping_interval_seconds,
})
function normalizeJsonText(value: string, fallback: string) {
const trimmed = (value ?? '').toString().trim()
return trimmed ? trimmed : fallback
}
type GlobalSettingsCardProps = {
defaultValues: GlobalModelSettingsFormValues
}
export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const form = useForm<
GlobalModelSettingsFormInput,
unknown,
GlobalModelSettingsFormValues
>({
resolver: zodResolver(schema),
defaultValues: defaultValues as GlobalModelSettingsFormInput,
})
useEffect(() => {
form.reset(defaultValues as GlobalModelSettingsFormInput)
}, [defaultValues, form])
const pingEnabled = form.watch('general_setting.ping_interval_enabled')
const formatJsonField = (
field:
| 'global.thinking_model_blacklist'
| 'global.chat_completions_to_responses_policy'
) => {
const raw = form.getValues(field)
if (!raw || !raw.trim()) return
try {
const formatted = JSON.stringify(JSON.parse(raw), null, 2)
form.setValue(field, formatted, { shouldDirty: true })
} catch {
toast.error(t('Invalid JSON format'))
}
}
const onSubmit = async (values: GlobalModelSettingsFormValues) => {
const flattenedDefaults = flattenGlobalValues(defaultValues)
const flattenedValues = flattenGlobalValues(values)
const updates = Object.entries(flattenedValues).filter(
([key, value]) =>
value !== flattenedDefaults[key as keyof FlatGlobalModelSettings]
)
if (updates.length === 0) {
toast.info(t('No changes to save'))
return
}
for (const [key, value] of updates) {
await updateOption.mutateAsync({
key,
value,
})
}
}
return (
<SettingsSection
title={t('Global Model Configuration')}
description={t(
'Control passthrough behavior and connection keep-alive settings'
)}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='global.pass_through_request_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable Request Passthrough')}
</FormLabel>
<FormDescription>
{t(
'Forward requests directly to upstream providers without any post-processing.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='global.thinking_model_blacklist'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Disable thinking processing models')}</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder={`${t('Example:')}\n${thinkingBlacklistExample}`}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
)}
</FormDescription>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
formatJsonField('global.thinking_model_blacklist')
}
>
{t('Format JSON')}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h3 className='text-base font-semibold'>
{t('ChatCompletions -> Responses Compatibility')}
</h3>
<StatusBadge
label={t('Preview')}
variant='neutral'
copyable={false}
/>
</div>
<Alert>
<AlertTitle>{t('Warning')}</AlertTitle>
<AlertDescription>
{t(
'This feature is experimental. Configuration format and behavior may change.'
)}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name='global.chat_completions_to_responses_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Policy JSON')}</FormLabel>
<FormControl>
<Textarea
rows={8}
placeholder={`${t('Example (specific channels):')}\n${chatToResponsesPolicyExample}\n\n${t('Example (all channels):')}\n${chatToResponsesPolicyAllChannelsExample}`}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Empty value will be saved as {}.')}
</FormDescription>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
form.setValue(
'global.chat_completions_to_responses_policy',
chatToResponsesPolicyExample,
{ shouldDirty: true }
)
}
>
{t('Fill example (specific channels)')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
form.setValue(
'global.chat_completions_to_responses_policy',
chatToResponsesPolicyAllChannelsExample,
{ shouldDirty: true }
)
}
>
{t('Fill example (all channels)')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
formatJsonField(
'global.chat_completions_to_responses_policy'
)
}
>
{t('Format JSON')}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<FormField
control={form.control}
name='general_setting.ping_interval_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Keep-alive Ping')}
</FormLabel>
<FormDescription>
{t(
'Periodically send ping frames to keep streaming connections active.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='general_setting.ping_interval_seconds'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Ping Interval (seconds)')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
disabled={!pingEnabled}
className='w-24'
value={
field.value === undefined || field.value === null
? ''
: String(field.value)
}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t(
'Recommended to keep this high to avoid upstream throttling.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,132 @@
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
const XAI_VIOLATION_FEE_DOC_URL =
'https://docs.x.ai/docs/models#usage-guidelines-violation-fee'
const grokSchema = z.object({
'grok.violation_deduction_enabled': z.boolean(),
'grok.violation_deduction_amount': z.coerce.number().min(0),
})
type GrokFormValues = z.infer<typeof grokSchema>
interface Props {
defaultValues: GrokFormValues
}
export function GrokSettingsCard(props: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const form = useForm<GrokFormValues>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(grokSchema) as any,
defaultValues: props.defaultValues,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useResetForm(form as any, props.defaultValues)
const onSubmit = async (data: GrokFormValues) => {
const entries = Object.entries(data) as [string, unknown][]
const updates = entries.filter(
([key, value]) =>
value !== (props.defaultValues[key as keyof GrokFormValues] as unknown)
)
for (const [key, value] of updates) {
await updateOption.mutateAsync({
key,
value: value as string | number | boolean,
})
}
}
const enabled = form.watch('grok.violation_deduction_enabled')
return (
<SettingsSection
title={t('Grok Settings')}
description={t('Configure xAI Grok model specific settings')}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='grok.violation_deduction_enabled'
render={({ field }) => (
<FormItem className='flex items-center gap-2'>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>{t('Enable violation deduction')}</FormLabel>
<FormDescription>
{t(
'When enabled, violation requests will incur additional charges.'
)}{' '}
<a
href={XAI_VIOLATION_FEE_DOC_URL}
target='_blank'
rel='noreferrer'
className='underline'
>
{t('Official documentation')}
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name='grok.violation_deduction_amount'
render={({ field }) => (
<FormItem className='max-w-xs'>
<FormLabel>{t('Violation deduction amount')}</FormLabel>
<FormControl>
<Input
type='number'
step={0.01}
min={0}
{...field}
disabled={!enabled}
/>
</FormControl>
<FormDescription>
{t(
'Base amount. Actual deduction = base amount × system group rate.'
)}
</FormDescription>
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,278 @@
import { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
type GroupFormValues = {
GroupRatio: string
TopupGroupRatio: string
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
DefaultUseAutoGroup: boolean
GroupSpecialUsableGroup: string
}
type GroupRatioFormProps = {
form: UseFormReturn<GroupFormValues>
onSave: (values: GroupFormValues) => Promise<void>
isSaving: boolean
}
export const GroupRatioForm = memo(function GroupRatioForm({
form,
onSave,
isSaving,
}: GroupRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const handleFieldChange = useCallback(
(field: keyof GroupFormValues, value: string) => {
form.setValue(field, value, {
shouldValidate: true,
shouldDirty: true,
})
},
[form]
)
const toggleEditMode = useCallback(() => {
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
)}
</Button>
</div>
<Form {...form}>
{editMode === 'visual' ? (
<div className='space-y-6'>
<GroupRatioVisualEditor
groupRatio={form.watch('GroupRatio')}
topupGroupRatio={form.watch('TopupGroupRatio')}
userUsableGroups={form.watch('UserUsableGroups')}
groupGroupRatio={form.watch('GroupGroupRatio')}
autoGroups={form.watch('AutoGroups')}
onChange={(field, value) =>
handleFieldChange(field as keyof GroupFormValues, value)
}
/>
<GroupSpecialUsableRulesEditor
value={form.watch('GroupSpecialUsableGroup')}
onChange={(value) =>
handleFieldChange('GroupSpecialUsableGroup', value)
}
/>
<FormField
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Default to auto groups')}
</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</div>
) : (
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
<FormField
control={form.control}
name='GroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group ratios')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of group → ratio applied when the user selects the group explicitly.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='TopupGroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Top-up group ratios')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as'
)}
{` { "default": 1, "vip": 1.2 }`}.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='UserUsableGroups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Selectable groups')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of group → description exposed when users create API keys.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupGroupRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Inter-group overrides')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Nested JSON: source group →')}{' '}
{`{ targetGroup: ratio }`}{' '}
{t(
'to override billing when a user in one group uses a token of another group.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutoGroups'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto assignment order')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON array of group identifiers. When enabled below, new tokens rotate through this list.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupSpecialUsableGroup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Special usable group rules')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Default to auto groups')}
</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type='submit' disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</form>
)}
</Form>
</div>
)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
import { useCallback, useMemo, useState } from 'react'
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { StatusBadge } from '@/components/status-badge'
const OP_ADD = 'add' as const
const OP_REMOVE = 'remove' as const
const OP_APPEND = 'append' as const
type OpType = typeof OP_ADD | typeof OP_REMOVE | typeof OP_APPEND
type Rule = {
_id: string
userGroup: string
op: OpType
targetGroup: string
description: string
}
let _idCounter = 0
function uid() {
return `gsu_${++_idCounter}`
}
function parsePrefix(rawKey: string): { op: OpType; groupName: string } {
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) }
if (rawKey.startsWith('-:'))
return { op: OP_REMOVE, groupName: rawKey.slice(2) }
return { op: OP_APPEND, groupName: rawKey }
}
function toRawKey(op: OpType, groupName: string): string {
if (op === OP_ADD) return `+:${groupName}`
if (op === OP_REMOVE) return `-:${groupName}`
return groupName
}
function safeParseJson(str: string): Record<string, Record<string, string>> {
if (!str || !str.trim()) return {}
try {
return JSON.parse(str) as Record<string, Record<string, string>>
} catch {
return {}
}
}
function flattenRules(nested: Record<string, Record<string, string>>): Rule[] {
const rules: Rule[] = []
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue
for (const [rawKey, desc] of Object.entries(inner)) {
const { op, groupName } = parsePrefix(rawKey)
rules.push({
_id: uid(),
userGroup,
op,
targetGroup: groupName,
description:
op === OP_REMOVE ? 'remove' : typeof desc === 'string' ? desc : '',
})
}
}
return rules
}
function nestRules(rules: Rule[]): Record<string, Record<string, string>> {
const result: Record<string, Record<string, string>> = {}
for (const { userGroup, op, targetGroup, description } of rules) {
if (!userGroup || !targetGroup) continue
if (!result[userGroup]) result[userGroup] = {}
result[userGroup][toRawKey(op, targetGroup)] = description
}
return result
}
function serializeRules(rules: Rule[]): string {
const nested = nestRules(rules)
return Object.keys(nested).length === 0
? '{}'
: JSON.stringify(nested, null, 2)
}
const OP_BADGE_MAP: Record<
OpType,
{ variant: 'info' | 'danger' | 'neutral'; label: string }
> = {
[OP_ADD]: { variant: 'info', label: 'Add (+:)' },
[OP_REMOVE]: { variant: 'danger', label: 'Remove (-:)' },
[OP_APPEND]: { variant: 'neutral', label: 'Append' },
}
type GroupSpecialUsableRulesEditorProps = {
value: string
onChange: (value: string) => void
}
type GroupSectionProps = {
groupName: string
items: Rule[]
onUpdate: (id: string, field: keyof Rule, val: string) => void
onRemove: (id: string) => void
onAdd: (groupName: string) => void
onRemoveGroup: (groupName: string) => void
}
function GroupSection(props: GroupSectionProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className='rounded-lg border'>
<div className='flex items-center justify-between p-3'>
<div className='flex items-center gap-2'>
<CollapsibleTrigger asChild>
<Button variant='ghost' size='sm' className='h-6 w-6 p-0'>
{open ? (
<ChevronUp className='h-4 w-4' />
) : (
<ChevronDown className='h-4 w-4' />
)}
</Button>
</CollapsibleTrigger>
<span className='font-semibold'>{props.groupName}</span>
<StatusBadge variant='neutral' copyable={false}>
{props.items.length} {t('rules')}
</StatusBadge>
</div>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
className='h-7 w-7 p-0'
onClick={() => props.onAdd(props.groupName)}
>
<Plus className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
className='text-destructive h-7 w-7 p-0'
onClick={() => props.onRemoveGroup(props.groupName)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
<CollapsibleContent>
<div className='space-y-2 border-t p-3'>
{props.items.map((rule) => (
<div key={rule._id} className='flex items-center gap-2'>
<Select
value={rule.op}
onValueChange={(v) => props.onUpdate(rule._id, 'op', v)}
>
<SelectTrigger className='w-[130px]'>
<SelectValue>
<StatusBadge
label={t(OP_BADGE_MAP[rule.op].label)}
variant={OP_BADGE_MAP[rule.op].variant}
copyable={false}
/>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={OP_ADD}>
<StatusBadge
label={t(OP_BADGE_MAP[OP_ADD].label)}
variant={OP_BADGE_MAP[OP_ADD].variant}
copyable={false}
/>
</SelectItem>
<SelectItem value={OP_REMOVE}>
<StatusBadge
label={t(OP_BADGE_MAP[OP_REMOVE].label)}
variant={OP_BADGE_MAP[OP_REMOVE].variant}
copyable={false}
/>
</SelectItem>
<SelectItem value={OP_APPEND}>
<StatusBadge
label={t(OP_BADGE_MAP[OP_APPEND].label)}
variant={OP_BADGE_MAP[OP_APPEND].variant}
copyable={false}
/>
</SelectItem>
</SelectContent>
</Select>
<Input
className='flex-1'
value={rule.targetGroup}
placeholder={t('Group name')}
onChange={(e) =>
props.onUpdate(rule._id, 'targetGroup', e.target.value)
}
/>
{rule.op !== OP_REMOVE ? (
<Input
className='flex-1'
value={rule.description}
placeholder={t('Description')}
onChange={(e) =>
props.onUpdate(rule._id, 'description', e.target.value)
}
/>
) : (
<div className='text-muted-foreground flex-1 px-3 text-sm'>
-
</div>
)}
<Button
variant='ghost'
size='sm'
className='text-destructive h-8 w-8 p-0'
onClick={() => props.onRemove(rule._id)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}
export function GroupSpecialUsableRulesEditor(
props: GroupSpecialUsableRulesEditorProps
) {
const { t } = useTranslation()
const [rules, setRules] = useState<Rule[]>(() =>
flattenRules(safeParseJson(props.value))
)
const [newGroupName, setNewGroupName] = useState('')
const { onChange } = props
const emitChange = useCallback(
(newRules: Rule[]) => {
setRules(newRules)
onChange(serializeRules(newRules))
},
[onChange]
)
const updateRule = useCallback(
(id: string, field: keyof Rule, val: string) => {
emitChange(
rules.map((r) => {
if (r._id !== id) return r
const updated = { ...r, [field]: val }
if (field === 'op' && val === OP_REMOVE)
updated.description = 'remove'
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
if (updated.description === 'remove') updated.description = ''
}
return updated
})
)
},
[rules, emitChange]
)
const removeRule = useCallback(
(id: string) => emitChange(rules.filter((r) => r._id !== id)),
[rules, emitChange]
)
const removeGroup = useCallback(
(groupName: string) =>
emitChange(rules.filter((r) => r.userGroup !== groupName)),
[rules, emitChange]
)
const addRuleToGroup = useCallback(
(groupName: string) => {
emitChange([
...rules,
{
_id: uid(),
userGroup: groupName,
op: OP_APPEND,
targetGroup: '',
description: '',
},
])
},
[rules, emitChange]
)
const addNewGroup = useCallback(() => {
const name = newGroupName.trim()
if (!name) return
emitChange([
...rules,
{
_id: uid(),
userGroup: name,
op: OP_APPEND,
targetGroup: '',
description: '',
},
])
setNewGroupName('')
}, [rules, emitChange, newGroupName])
const grouped = useMemo(() => {
const map: Record<string, Rule[]> = {}
const order: string[] = []
for (const r of rules) {
if (!r.userGroup) continue
if (!map[r.userGroup]) {
map[r.userGroup] = []
order.push(r.userGroup)
}
map[r.userGroup].push(r)
}
return order.map((name) => ({ name, items: map[name] }))
}, [rules])
return (
<Card>
<CardHeader>
<CardTitle>{t('Special usable group rules')}</CardTitle>
<CardDescription>
{t(
'Define per-group rules to add, remove, or append selectable groups for specific user groups.'
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-3'>
{grouped.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('No rules yet. Add a group below to get started.')}
</p>
) : (
grouped.map((group) => (
<GroupSection
key={group.name}
groupName={group.name}
items={group.items}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
onRemoveGroup={removeGroup}
/>
))
)}
<div className='flex items-center justify-center gap-2 pt-2'>
<Input
className='w-[200px]'
value={newGroupName}
placeholder={t('User group name')}
onChange={(e) => setNewGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addNewGroup()
}
}}
/>
<Button variant='outline' size='sm' onClick={addNewGroup}>
<Plus className='mr-1 h-4 w-4' />
{t('Add group rules')}
</Button>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,57 @@
import { SettingsPage } from '../components/settings-page'
import type { ModelSettings } from '../types'
import {
MODELS_DEFAULT_SECTION,
getModelsSectionContent,
} from './section-registry.tsx'
const defaultModelSettings: ModelSettings = {
'global.pass_through_request_enabled': false,
'global.thinking_model_blacklist': '[]',
'global.chat_completions_to_responses_policy': '{}',
'general_setting.ping_interval_enabled': false,
'general_setting.ping_interval_seconds': 60,
'gemini.safety_settings': '',
'gemini.version_settings': '',
'gemini.supported_imagine_models': '',
'gemini.thinking_adapter_enabled': false,
'gemini.thinking_adapter_budget_tokens_percentage': 0.6,
'gemini.function_call_thought_signature_enabled': true,
'gemini.remove_function_response_id_enabled': true,
'claude.model_headers_settings': '',
'claude.default_max_tokens': '',
'claude.thinking_adapter_enabled': true,
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
'grok.violation_deduction_enabled': true,
'grok.violation_deduction_amount': 0.05,
ModelPrice: '',
ModelRatio: '',
CacheRatio: '',
CreateCacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
'billing_setting.billing_mode': '{}',
'billing_setting.billing_expr': '{}',
'tool_price_setting.prices': '{}',
TopupGroupRatio: '',
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
}
export function ModelSettings() {
return (
<SettingsPage
routePath='/_authenticated/system-settings/models/$section'
defaultSettings={defaultModelSettings}
defaultSection={MODELS_DEFAULT_SECTION}
getSectionContent={getModelsSectionContent}
/>
)
}
@@ -0,0 +1,654 @@
import { useEffect, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { TieredPricingEditor } from './tiered-pricing-editor'
const createModelDialogSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, t('Model name is required')),
price: z.string().optional(),
ratio: z.string().optional(),
cacheRatio: z.string().optional(),
createCacheRatio: z.string().optional(),
completionRatio: z.string().optional(),
imageRatio: z.string().optional(),
audioRatio: z.string().optional(),
audioCompletionRatio: z.string().optional(),
})
type ModelDialogFormValues = z.infer<ReturnType<typeof createModelDialogSchema>>
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
type PricingSubMode = 'ratio' | 'price'
export type ModelRatioData = {
name: string
price?: string
ratio?: string
cacheRatio?: string
createCacheRatio?: string
completionRatio?: string
imageRatio?: string
audioRatio?: string
audioCompletionRatio?: string
billingMode?: 'per-token' | 'per-request' | 'tiered_expr'
billingExpr?: string
requestRuleExpr?: string
}
type ModelRatioDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (data: ModelRatioData) => void
editData?: ModelRatioData | null
}
export function ModelRatioDialog({
open,
onOpenChange,
onSave,
editData,
}: ModelRatioDialogProps) {
const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [pricingSubMode, setPricingSubMode] = useState<PricingSubMode>('ratio')
const [advancedOpen, setAdvancedOpen] = useState(false)
const [promptPrice, setPromptPrice] = useState('')
const [completionPrice, setCompletionPrice] = useState('')
const [billingExpr, setBillingExpr] = useState('')
const [requestRuleExpr, setRequestRuleExpr] = useState('')
const isEditMode = !!editData
const form = useForm<ModelDialogFormValues>({
resolver: zodResolver(createModelDialogSchema(t)),
defaultValues: {
name: '',
price: '',
ratio: '',
cacheRatio: '',
createCacheRatio: '',
completionRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
},
})
useEffect(() => {
if (editData) {
form.reset(editData)
// eslint-disable-next-line react-hooks/set-state-in-effect
setBillingExpr(editData.billingExpr || '')
setRequestRuleExpr(editData.requestRuleExpr || '')
if (editData.billingMode === 'tiered_expr') {
setPricingMode('tiered_expr')
} else if (editData.price && editData.price !== '') {
setPricingMode('per-request')
} else {
setPricingMode('per-token')
if (editData.ratio) {
const tokenPrice = parseFloat(editData.ratio) * 2
setPromptPrice(tokenPrice.toString())
if (editData.completionRatio) {
const compPrice = tokenPrice * parseFloat(editData.completionRatio)
setCompletionPrice(compPrice.toString())
}
}
}
} else {
form.reset({
name: '',
price: '',
ratio: '',
cacheRatio: '',
createCacheRatio: '',
completionRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
})
setPricingMode('per-token')
setPricingSubMode('ratio')
setPromptPrice('')
setCompletionPrice('')
setBillingExpr('')
setRequestRuleExpr('')
setAdvancedOpen(false)
}
}, [editData, form, open])
const handleSubmit = (values: ModelDialogFormValues) => {
// Always pass through every field. The visual editor decides what to
// persist based on `billingMode`, and tiered_expr models also keep the
// ratio/price values as fallback during multi-instance sync delays
// (the backend's ModelPriceHelper checks billing_mode first, so these
// fallbacks only kick in when billing_setting hasn't propagated yet).
const data: ModelRatioData = {
name: values.name,
billingMode: pricingMode,
price: values.price || '',
ratio: values.ratio || '',
cacheRatio: values.cacheRatio || '',
createCacheRatio: values.createCacheRatio || '',
completionRatio: values.completionRatio || '',
imageRatio: values.imageRatio || '',
audioRatio: values.audioRatio || '',
audioCompletionRatio: values.audioCompletionRatio || '',
}
if (pricingMode === 'tiered_expr') {
data.billingExpr = billingExpr
data.requestRuleExpr = requestRuleExpr
}
onSave(data)
form.reset()
onOpenChange(false)
}
const validateNumber = (value: string) => {
if (value === '') return true
return !isNaN(parseFloat(value))
}
const handlePromptPriceChange = (value: string) => {
setPromptPrice(value)
if (value && !isNaN(parseFloat(value))) {
const ratio = parseFloat(value) / 2
form.setValue('ratio', ratio.toString())
} else {
form.setValue('ratio', '')
}
}
const handleCompletionPriceChange = (value: string) => {
setCompletionPrice(value)
if (
value &&
!isNaN(parseFloat(value)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice)) &&
parseFloat(promptPrice) > 0
) {
const completionRatio = parseFloat(value) / parseFloat(promptPrice)
form.setValue('completionRatio', completionRatio.toString())
} else {
form.setValue('completionRatio', '')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[680px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit model') : t('Add model')}
</DialogTitle>
<DialogDescription>
{t('Configure pricing ratios for a specific model.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
autoComplete='off'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t('The exact model identifier as used in API requests.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-4'>
<Label>{t('Pricing mode')}</Label>
<RadioGroup
value={pricingMode}
onValueChange={(value) => setPricingMode(value as PricingMode)}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-token' id='per-token' />
<Label htmlFor='per-token' className='font-normal'>
{t('Per-token (ratio based)')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-request' id='per-request' />
<Label htmlFor='per-request' className='font-normal'>
{t('Per-request (fixed price)')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='tiered_expr' id='tiered_expr' />
<Label htmlFor='tiered_expr' className='font-normal'>
{t('Tiered (billing expression)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingMode === 'tiered_expr' ? (
<TieredPricingEditor
modelName={form.getValues('name')}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
) : pricingMode === 'per-request' ? (
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Fixed price (USD)')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.01'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Cost in USD per request, regardless of tokens used.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
<div className='space-y-4'>
<Label>{t('Input mode')}</Label>
<RadioGroup
value={pricingSubMode}
onValueChange={(value) =>
setPricingSubMode(value as PricingSubMode)
}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='ratio' id='ratio' />
<Label htmlFor='ratio' className='font-normal'>
{t('Ratio mode')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='price' id='price' />
<Label htmlFor='price' className='font-normal'>
{t('Price mode (USD per 1M tokens)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingSubMode === 'ratio' ? (
<>
<FormField
control={form.control}
name='ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
if (value) {
setPromptPrice(
(parseFloat(value) * 2).toString()
)
} else {
setPromptPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value && !isNaN(parseFloat(field.value))
? t(
'Calculated price: ${{price}} per 1M tokens',
{
price: (
parseFloat(field.value) * 2
).toFixed(4),
}
)
: t('Multiplier for prompt tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='completionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
const ratio = form.getValues('ratio')
if (value && ratio) {
const compPrice =
parseFloat(ratio) * 2 * parseFloat(value)
setCompletionPrice(compPrice.toString())
} else {
setCompletionPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value &&
!isNaN(parseFloat(field.value)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice))
? t(
'Calculated price: ${{price}} per 1M tokens',
{
price: (
parseFloat(promptPrice) *
parseFloat(field.value)
).toFixed(4),
}
)
: t('Multiplier for completion tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Prompt price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='2.0'
value={promptPrice}
onChange={(e) =>
handlePromptPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{promptPrice && !isNaN(parseFloat(promptPrice))
? t('Calculated ratio: {{ratio}}', {
ratio: (parseFloat(promptPrice) / 2).toFixed(4),
})
: t('Enter Input price to calculate ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Completion price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='4.0'
value={completionPrice}
onChange={(e) =>
handleCompletionPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{completionPrice &&
!isNaN(parseFloat(completionPrice)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice)) &&
parseFloat(promptPrice) > 0
? t('Calculated ratio: {{ratio}}', {
ratio: (
parseFloat(completionPrice) /
parseFloat(promptPrice)
).toFixed(4),
})
: t('Enter Completion price to calculate ratio')}
</p>
</div>
</div>
</>
)}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
type='button'
variant='outline'
className='flex w-full items-center justify-between'
>
{t('Advanced options')}
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
advancedOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<FormField
control={form.control}
name='cacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Cache ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.1'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Discount ratio for cache hits.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='createCacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Create cache ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.25'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t(
'Ratio applied when creating cache entries for supported models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='imageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for image processing.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio inputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio outputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,342 @@
import { memo, useCallback, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
type ModelFormValues = {
ModelPrice: string
ModelRatio: string
CacheRatio: string
CreateCacheRatio: string
CompletionRatio: string
ImageRatio: string
AudioRatio: string
AudioCompletionRatio: string
ExposeRatioEnabled: boolean
BillingMode: string
BillingExpr: string
}
type ModelRatioFormProps = {
form: UseFormReturn<ModelFormValues>
onSave: (values: ModelFormValues) => Promise<void>
onReset: () => void
isSaving: boolean
isResetting: boolean
}
export const ModelRatioForm = memo(function ModelRatioForm({
form,
onSave,
onReset,
isSaving,
isResetting,
}: ModelRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
form.setValue(field, value, {
shouldValidate: true,
shouldDirty: true,
})
},
[form]
)
const toggleEditMode = useCallback(() => {
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
)}
</Button>
</div>
<Form {...form}>
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')}
createCacheRatio={form.watch('CreateCacheRatio')}
completionRatio={form.watch('CompletionRatio')}
imageRatio={form.watch('ImageRatio')}
audioRatio={form.watch('AudioRatio')}
audioCompletionRatio={form.watch('AudioCompletionRatio')}
billingMode={form.watch('BillingMode')}
billingExpr={form.watch('BillingExpr')}
onChange={(field, value) => {
const fieldMap: Record<string, keyof ModelFormValues> = {
'billing_setting.billing_mode': 'BillingMode',
'billing_setting.billing_expr': 'BillingExpr',
}
const formField =
fieldMap[field] || (field as keyof ModelFormValues)
handleFieldChange(formField, value)
}}
/>
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Expose ratio API')}
</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className='flex flex-wrap gap-4'>
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model ratios')}
</Button>
<Button
type='button'
variant='destructive'
onClick={onReset}
disabled={isResetting}
>
{t('Reset ratios')}
</Button>
</div>
</div>
) : (
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
<FormField
control={form.control}
name='ModelPrice'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model fixed pricing')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ModelRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of model → multiplier applied to quota billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Optional ratio used when upstream cache hits occur.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CreateCacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Create cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied when creating cache entries for supported models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Applies to custom completion endpoints. JSON map of model → ratio.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ImageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Configure per-model ratio for image inputs or outputs.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio inputs where supported by the upstream model.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio completions for streaming models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Expose ratio API')}
</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className='flex flex-wrap gap-4'>
<Button type='submit' disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model ratios')}
</Button>
<Button
type='button'
variant='destructive'
onClick={onReset}
disabled={isResetting}
>
{t('Reset ratios')}
</Button>
</div>
</form>
)}
</Form>
</div>
)
})
@@ -0,0 +1,786 @@
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
import {
type ColumnDef,
type PaginationState,
type VisibilityState,
type SortingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DataTableColumnHeader,
DataTableToolbar,
DataTablePagination,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import {
combineBillingExpr,
splitBillingExprAndRequestRules,
} from '@/features/pricing/lib/billing-expr'
import { safeJsonParse } from '../utils/json-parser'
import { ModelRatioDialog, type ModelRatioData } from './model-ratio-dialog'
type ModelRatioVisualEditorProps = {
modelPrice: string
modelRatio: string
cacheRatio: string
createCacheRatio: string
completionRatio: string
imageRatio: string
audioRatio: string
audioCompletionRatio: string
billingMode: string
billingExpr: string
onChange: (field: string, value: string) => void
}
type ModelRow = {
name: string
price?: string
ratio?: string
cacheRatio?: string
createCacheRatio?: string
completionRatio?: string
imageRatio?: string
audioRatio?: string
audioCompletionRatio?: string
billingMode?: string
billingExpr?: string
requestRuleExpr?: string
hasConflict: boolean
}
const STORAGE_KEY = 'model-ratio-column-visibility'
const formatValue = (value?: string) => {
if (!value || value === '') return '—'
return value
}
export const ModelRatioVisualEditor = memo(
function ModelRatioVisualEditor({
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
}: ModelRatioVisualEditorProps) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false)
const [editData, setEditData] = useState<ModelRatioData | null>(null)
const [sorting, setSorting] = useState<SortingState>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
return safeJsonParse<VisibilityState>(saved, {
fallback: {
cacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
},
silent: true,
})
} catch {
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
}
return {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
}
}
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility))
}, [columnVisibility])
const models = useMemo(() => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, context: 'completion ratios' }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{
fallback: {},
context: 'billing mode',
}
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{
fallback: {},
context: 'billing expression',
}
)
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
const modelData: ModelRow[] = Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
// Tiered_expr models may also retain ratio/price values as fallback
// during multi-instance sync delays. We preserve them in the row so
// the edit dialog round-trip and the next save don't drop them.
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
}
}
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
return modelData.sort((a, b) => a.name.localeCompare(b.name))
}, [
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
])
const handleEdit = useCallback((model: ModelRow) => {
setEditData({
name: model.name,
price: model.price,
ratio: model.ratio,
cacheRatio: model.cacheRatio,
createCacheRatio: model.createCacheRatio,
completionRatio: model.completionRatio,
imageRatio: model.imageRatio,
audioRatio: model.audioRatio,
audioCompletionRatio: model.audioCompletionRatio,
billingMode:
model.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
})
setDialogOpen(true)
}, [])
const handleAdd = useCallback(() => {
setEditData(null)
setDialogOpen(true)
}, [])
const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
delete priceMap[name]
delete ratioMap[name]
delete cacheMap[name]
delete createCacheMap[name]
delete completionMap[name]
delete imageMap[name]
delete audioMap[name]
delete audioCompletionMap[name]
delete billingModeMap[name]
delete billingExprMap[name]
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
)
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
// Ratio fields are not the primary pricing when a per-request fixed
// price is set, or when the model is in tiered_expr mode (the
// expression is primary; ratios are fallback during sync delays).
const isFallbackRow = (row: ModelRow) =>
row.billingMode === 'tiered_expr' || !!row.price
const fallbackClass = (row: ModelRow) =>
isFallbackRow(row) ? 'text-muted-foreground' : ''
return [
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge
label={t('Tiered')}
variant='info'
copyable={false}
/>
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'price',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Fixed price')} />
),
cell: ({ row }) => (
<span
className={
row.original.billingMode === 'tiered_expr'
? 'text-muted-foreground'
: ''
}
>
{formatValue(row.getValue('price'))}
</span>
),
meta: { label: 'Fixed price' },
},
{
accessorKey: 'ratio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Ratio')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('ratio'))}
</span>
),
meta: { label: 'Ratio' },
},
{
accessorKey: 'completionRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Completion')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('completionRatio'))}
</span>
),
meta: { label: 'Completion' },
},
{
accessorKey: 'cacheRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Cache')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('cacheRatio'))}
</span>
),
meta: { label: 'Cache' },
},
{
accessorKey: 'createCacheRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Create cache')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('createCacheRatio'))}
</span>
),
meta: { label: 'Create cache' },
},
{
accessorKey: 'imageRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Image')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('imageRatio'))}
</span>
),
meta: { label: 'Image' },
},
{
accessorKey: 'audioRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Audio')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('audioRatio'))}
</span>
),
meta: { label: 'Audio' },
},
{
accessorKey: 'audioCompletionRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Audio comp.')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('audioCompletionRatio'))}
</span>
),
meta: { label: 'Audio comp.' },
},
{
id: 'actions',
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
),
enableHiding: false,
},
]
}, [handleEdit, handleDelete, t])
const table = useReactTable({
data: models,
columns,
state: {
sorting,
columnVisibility,
pagination,
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
autoResetPageIndex: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase()
return row.original.name.toLowerCase().includes(searchValue)
},
})
const handleSave = useCallback(
(data: ModelRatioData) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
silent: true,
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
silent: true,
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
completionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
silent: true,
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
silent: true,
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, silent: true }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{ fallback: {}, silent: true }
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{ fallback: {}, silent: true }
)
delete priceMap[data.name]
delete ratioMap[data.name]
delete cacheMap[data.name]
delete createCacheMap[data.name]
delete completionMap[data.name]
delete imageMap[data.name]
delete audioMap[data.name]
delete audioCompletionMap[data.name]
delete billingModeMap[data.name]
delete billingExprMap[data.name]
const setIfPresent = (
target: Record<string, number>,
value: string | undefined
) => {
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[data.name] = parsed
}
if (data.billingMode === 'tiered_expr') {
const combined = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
if (combined) {
billingModeMap[data.name] = 'tiered_expr'
billingExprMap[data.name] = combined
}
// Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, data.price)
setIfPresent(ratioMap, data.ratio)
setIfPresent(cacheMap, data.cacheRatio)
setIfPresent(createCacheMap, data.createCacheRatio)
setIfPresent(completionMap, data.completionRatio)
setIfPresent(imageMap, data.imageRatio)
setIfPresent(audioMap, data.audioRatio)
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, data.price)
} else {
setIfPresent(ratioMap, data.ratio)
setIfPresent(cacheMap, data.cacheRatio)
setIfPresent(createCacheMap, data.createCacheRatio)
setIfPresent(completionMap, data.completionRatio)
setIfPresent(imageMap, data.imageRatio)
setIfPresent(audioMap, data.audioRatio)
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
}
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
onChange('CacheRatio', JSON.stringify(cacheMap, null, 2))
onChange('CreateCacheRatio', JSON.stringify(createCacheMap, null, 2))
onChange('CompletionRatio', JSON.stringify(completionMap, null, 2))
onChange('ImageRatio', JSON.stringify(imageMap, null, 2))
onChange('AudioRatio', JSON.stringify(audioMap, null, 2))
onChange(
'AudioCompletionRatio',
JSON.stringify(audioCompletionMap, null, 2)
)
onChange(
'billing_setting.billing_mode',
JSON.stringify(billingModeMap, null, 2)
)
onChange(
'billing_setting.billing_expr',
JSON.stringify(billingExprMap, null, 2)
)
},
[
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
onChange,
]
)
return (
<div className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Search models...')}
/>
<Button onClick={handleAdd}>
<Plus className='mr-2 h-4 w-4' />
{t('Add model')}
</Button>
</div>
{table.getRowModel().rows.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{table.getState().globalFilter
? t('No models match your search')
: t('No models configured. Click "Add model" to get started.')}
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
<ModelRatioDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
editData={editData}
/>
</div>
)
},
// Custom equality check - only re-render if JSON props actually changed
(prevProps, nextProps) => {
return (
prevProps.modelPrice === nextProps.modelPrice &&
prevProps.modelRatio === nextProps.modelRatio &&
prevProps.cacheRatio === nextProps.cacheRatio &&
prevProps.createCacheRatio === nextProps.createCacheRatio &&
prevProps.completionRatio === nextProps.completionRatio &&
prevProps.imageRatio === nextProps.imageRatio &&
prevProps.audioRatio === nextProps.audioRatio &&
prevProps.audioCompletionRatio === nextProps.audioCompletionRatio &&
prevProps.billingMode === nextProps.billingMode &&
prevProps.billingExpr === nextProps.billingExpr &&
prevProps.onChange === nextProps.onChange
)
}
)
@@ -0,0 +1,488 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetModelRatios } from '../api'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings'
import { UpstreamRatioSync } from './upstream-ratio-sync'
import {
formatJsonForTextarea,
normalizeJsonString,
validateJsonString,
} from './utils'
const modelSchema = z.object({
ModelPrice: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ModelRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CreateCacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ImageRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ExposeRatioEnabled: z.boolean(),
BillingMode: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
BillingExpr: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
const groupSchema = z.object({
GroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
TopupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
UserUsableGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
GroupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AutoGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
predicate: (parsed) =>
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
})
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON array',
})
}
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
type ModelFormValues = z.infer<typeof modelSchema>
type GroupFormValues = z.infer<typeof groupSchema>
type RatioSettingsCardProps = {
modelDefaults: ModelFormValues
groupDefaults: GroupFormValues
toolPricesDefault: string
}
export function RatioSettingsCard({
modelDefaults,
groupDefaults,
toolPricesDefault,
}: RatioSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const resetMutation = useMutation({
mutationFn: resetModelRatios,
onSuccess: (data) => {
if (data.success) {
toast.success(t('Model ratios reset successfully'))
queryClient.invalidateQueries({ queryKey: ['system-options'] })
setConfirmOpen(false)
} else {
toast.error(data.message || t('Failed to reset model ratios'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to reset model ratios'))
},
})
const modelNormalizedDefaults = useRef({
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
})
const groupNormalizedDefaults = useRef({
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
})
const modelForm = useForm<ModelFormValues>({
resolver: zodResolver(modelSchema),
mode: 'onChange',
defaultValues: {
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
},
})
const groupForm = useForm<GroupFormValues>({
resolver: zodResolver(groupSchema),
mode: 'onChange',
defaultValues: {
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
},
})
useEffect(() => {
modelNormalizedDefaults.current = {
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
}
modelForm.reset({
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
})
}, [modelDefaults, modelForm])
useEffect(() => {
groupNormalizedDefaults.current = {
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
}
groupForm.reset({
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
})
}, [groupDefaults, groupForm])
const saveModelRatios = useCallback(
async (values: ModelFormValues) => {
const normalized = {
ModelPrice: normalizeJsonString(values.ModelPrice),
ModelRatio: normalizeJsonString(values.ModelRatio),
CacheRatio: normalizeJsonString(values.CacheRatio),
CreateCacheRatio: normalizeJsonString(values.CreateCacheRatio),
CompletionRatio: normalizeJsonString(values.CompletionRatio),
ImageRatio: normalizeJsonString(values.ImageRatio),
AudioRatio: normalizeJsonString(values.AudioRatio),
AudioCompletionRatio: normalizeJsonString(values.AudioCompletionRatio),
ExposeRatioEnabled: values.ExposeRatioEnabled,
BillingMode: normalizeJsonString(values.BillingMode),
BillingExpr: normalizeJsonString(values.BillingExpr),
}
const apiKeyMap: Record<string, string> = {
BillingMode: 'billing_setting.billing_mode',
BillingExpr: 'billing_setting.billing_expr',
}
const updates = (
Object.keys(normalized) as Array<keyof ModelFormValues>
).filter(
(key) => normalized[key] !== modelNormalizedDefaults.current[key]
)
for (const key of updates) {
const apiKey = apiKeyMap[key as string] || (key as string)
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
},
[updateOption]
)
const saveGroupRatios = useCallback(
async (values: GroupFormValues) => {
const normalized = {
GroupRatio: normalizeJsonString(values.GroupRatio),
TopupGroupRatio: normalizeJsonString(values.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup
),
}
// Map form field names to API keys (most are 1:1, except GroupSpecialUsableGroup)
const apiKeyMap: Record<string, string> = {
GroupSpecialUsableGroup:
'group_ratio_setting.group_special_usable_group',
}
const updates = (
Object.keys(normalized) as Array<keyof typeof normalized>
).filter(
(key) => normalized[key] !== groupNormalizedDefaults.current[key]
)
for (const key of updates) {
const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
},
[updateOption]
)
const handleResetRatios = useCallback(() => {
setConfirmOpen(true)
}, [])
const { mutate: resetMutate } = resetMutation
const handleConfirmReset = useCallback(() => {
resetMutate()
}, [resetMutate])
return (
<SettingsSection
title={t('Pricing Ratios')}
description={t(
'Configure model, caching, and group ratios used for billing'
)}
>
<Tabs defaultValue='models' className='space-y-6'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='models'>{t('Model ratios')}</TabsTrigger>
<TabsTrigger value='groups'>{t('Group ratios')}</TabsTrigger>
<TabsTrigger value='tool-prices'>{t('Tool prices')}</TabsTrigger>
<TabsTrigger value='upstream-sync'>
{t('Upstream price sync')}
</TabsTrigger>
</TabsList>
<TabsContent value='models'>
<ModelRatioForm
form={modelForm}
onSave={saveModelRatios}
onReset={handleResetRatios}
isSaving={updateOption.isPending}
isResetting={resetMutation.isPending}
/>
</TabsContent>
<TabsContent value='groups'>
<GroupRatioForm
form={groupForm}
onSave={saveGroupRatios}
isSaving={updateOption.isPending}
/>
</TabsContent>
<TabsContent value='tool-prices'>
<ToolPriceSettings defaultValue={toolPricesDefault} />
</TabsContent>
<TabsContent value='upstream-sync'>
<UpstreamRatioSync
modelRatios={{
ModelPrice: modelDefaults.ModelPrice,
ModelRatio: modelDefaults.ModelRatio,
CompletionRatio: modelDefaults.CompletionRatio,
CacheRatio: modelDefaults.CacheRatio,
CreateCacheRatio: modelDefaults.CreateCacheRatio,
ImageRatio: modelDefaults.ImageRatio,
AudioRatio: modelDefaults.AudioRatio,
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
'billing_setting.billing_mode': modelDefaults.BillingMode,
'billing_setting.billing_expr': modelDefaults.BillingExpr,
}}
/>
</TabsContent>
</Tabs>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Reset all model ratios?')}
desc={t(
'This will clear custom pricing ratios and revert to upstream defaults.'
)}
destructive
isLoading={resetMutation.isPending}
handleConfirm={handleConfirmReset}
confirmText={t('Reset')}
/>
</SettingsSection>
)
}
@@ -0,0 +1,155 @@
import type { ModelSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { ClaudeSettingsCard } from './claude-settings-card'
import { GeminiSettingsCard } from './gemini-settings-card'
import { GlobalSettingsCard } from './global-settings-card'
import { GrokSettingsCard } from './grok-settings-card'
import { RatioSettingsCard } from './ratio-settings-card'
function formatJsonForEditor(value: string, fallback: string) {
const raw = (value ?? '').toString().trim()
if (!raw) return fallback
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return fallback
}
}
const MODELS_SECTIONS = [
{
id: 'global',
titleKey: 'Global Model Configuration',
descriptionKey: 'Configure global model settings',
build: (settings: ModelSettings) => (
<GlobalSettingsCard
defaultValues={{
global: {
pass_through_request_enabled:
settings['global.pass_through_request_enabled'],
thinking_model_blacklist: formatJsonForEditor(
settings['global.thinking_model_blacklist'],
'[]'
),
chat_completions_to_responses_policy: formatJsonForEditor(
settings['global.chat_completions_to_responses_policy'],
'{}'
),
},
general_setting: {
ping_interval_enabled:
settings['general_setting.ping_interval_enabled'],
ping_interval_seconds:
settings['general_setting.ping_interval_seconds'],
},
}}
/>
),
},
{
id: 'gemini',
titleKey: 'Gemini',
descriptionKey: 'Configure Gemini model settings',
build: (settings: ModelSettings) => (
<GeminiSettingsCard
defaultValues={{
gemini: {
safety_settings: settings['gemini.safety_settings'],
version_settings: settings['gemini.version_settings'],
supported_imagine_models:
settings['gemini.supported_imagine_models'],
thinking_adapter_enabled:
settings['gemini.thinking_adapter_enabled'],
thinking_adapter_budget_tokens_percentage:
settings['gemini.thinking_adapter_budget_tokens_percentage'],
function_call_thought_signature_enabled:
settings['gemini.function_call_thought_signature_enabled'],
remove_function_response_id_enabled:
settings['gemini.remove_function_response_id_enabled'],
},
}}
/>
),
},
{
id: 'claude',
titleKey: 'Claude',
descriptionKey: 'Configure Claude model settings',
build: (settings: ModelSettings) => (
<ClaudeSettingsCard
defaultValues={{
claude: {
model_headers_settings: settings['claude.model_headers_settings'],
default_max_tokens: settings['claude.default_max_tokens'],
thinking_adapter_enabled:
settings['claude.thinking_adapter_enabled'],
thinking_adapter_budget_tokens_percentage:
settings['claude.thinking_adapter_budget_tokens_percentage'],
},
}}
/>
),
},
{
id: 'grok',
titleKey: 'Grok',
descriptionKey: 'Configure xAI Grok model settings',
build: (settings: ModelSettings) => (
<GrokSettingsCard
defaultValues={{
'grok.violation_deduction_enabled':
settings['grok.violation_deduction_enabled'] ?? true,
'grok.violation_deduction_amount':
settings['grok.violation_deduction_amount'] ?? 0.05,
}}
/>
),
},
{
id: 'ratio',
titleKey: 'Pricing Ratios',
descriptionKey: 'Configure model pricing and ratio settings',
build: (settings: ModelSettings) => (
<RatioSettingsCard
modelDefaults={{
ModelPrice: settings.ModelPrice,
ModelRatio: settings.ModelRatio,
CacheRatio: settings.CacheRatio,
CreateCacheRatio: settings.CreateCacheRatio,
CompletionRatio: settings.CompletionRatio,
ImageRatio: settings.ImageRatio,
AudioRatio: settings.AudioRatio,
AudioCompletionRatio: settings.AudioCompletionRatio,
ExposeRatioEnabled: settings.ExposeRatioEnabled,
BillingMode: settings['billing_setting.billing_mode'],
BillingExpr: settings['billing_setting.billing_expr'],
}}
toolPricesDefault={settings['tool_price_setting.prices']}
groupDefaults={{
TopupGroupRatio: settings.TopupGroupRatio,
GroupRatio: settings.GroupRatio,
UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'],
}}
/>
),
},
] as const
export type ModelSectionId = (typeof MODELS_SECTIONS)[number]['id']
const modelsRegistry = createSectionRegistry<ModelSectionId, ModelSettings>({
sections: MODELS_SECTIONS,
defaultSection: 'global',
basePath: '/system-settings/models',
urlStyle: 'path',
})
export const MODELS_SECTION_IDS = modelsRegistry.sectionIds
export const MODELS_DEFAULT_SECTION = modelsRegistry.defaultSection
export const getModelsSectionNavItems = modelsRegistry.getSectionNavItems
export const getModelsSectionContent = modelsRegistry.getSectionContent
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Code2, Copy, Eye, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { useUpdateOption } from '../hooks/use-update-option'
const OPTION_KEY = 'tool_price_setting.prices'
const DEFAULT_PRICES: Record<string, number> = {
web_search: 10.0,
web_search_preview: 10.0,
'web_search_preview:gpt-4o*': 25.0,
'web_search_preview:gpt-4.1*': 25.0,
'web_search_preview:gpt-4o-mini*': 25.0,
'web_search_preview:gpt-4.1-mini*': 25.0,
file_search: 2.5,
google_search: 14.0,
}
type ToolPriceRow = {
id: number
key: string
price: number
}
function rowsToObject(rows: ToolPriceRow[]): Record<string, number> {
const prices: Record<string, number> = {}
for (const row of rows) {
const k = row.key.trim()
if (!k) continue
prices[k] = Number(row.price) || 0
}
return prices
}
function objectToRows(prices: Record<string, number>): ToolPriceRow[] {
return Object.entries(prices).map(([key, price], index) => ({
id: index + 1,
key,
price: Number(price) || 0,
}))
}
function parseInitialPrices(
rawValue: string | undefined
): Record<string, number> {
if (!rawValue) return { ...DEFAULT_PRICES }
try {
const parsed = JSON.parse(rawValue) as unknown
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Object.keys(parsed as object).length > 0
) {
return parsed as Record<string, number>
}
} catch {
// fall through to defaults
}
return { ...DEFAULT_PRICES }
}
type ToolPriceSettingsProps = {
defaultValue: string
}
export const ToolPriceSettings = memo(function ToolPriceSettings({
defaultValue,
}: ToolPriceSettingsProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<ToolPriceRow[]>([])
const [jsonText, setJsonText] = useState('')
const [jsonError, setJsonError] = useState('')
const [nextRowId, setNextRowId] = useState(1)
useEffect(() => {
const prices = parseInitialPrices(defaultValue)
const initialRows = objectToRows(prices)
// eslint-disable-next-line react-hooks/set-state-in-effect
setRows(initialRows)
setJsonText(JSON.stringify(prices, null, 2))
setJsonError('')
setNextRowId(initialRows.length + 1)
}, [defaultValue])
const currentPrices = useMemo(() => rowsToObject(rows), [rows])
const syncFromRows = useCallback((nextRows: ToolPriceRow[]) => {
setRows(nextRows)
setJsonText(JSON.stringify(rowsToObject(nextRows), null, 2))
setJsonError('')
}, [])
const handleJsonChange = useCallback(
(text: string) => {
setJsonText(text)
try {
const parsed = JSON.parse(text) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setJsonError(t('JSON must be an object'))
return
}
const nextRows = objectToRows(parsed as Record<string, number>)
setRows(nextRows)
setNextRowId(nextRows.length + 1)
setJsonError('')
} catch (error) {
setJsonError(error instanceof Error ? error.message : t('Invalid JSON'))
}
},
[t]
)
const updateRow = useCallback(
(id: number, field: 'key' | 'price', value: string | number) => {
syncFromRows(
rows.map((r) => (r.id === id ? { ...r, [field]: value } : r))
)
},
[rows, syncFromRows]
)
const addRow = useCallback(() => {
const newRow: ToolPriceRow = { id: nextRowId, key: '', price: 0 }
setNextRowId((prev) => prev + 1)
syncFromRows([...rows, newRow])
}, [nextRowId, rows, syncFromRows])
const removeRow = useCallback(
(id: number) => {
syncFromRows(rows.filter((r) => r.id !== id))
},
[rows, syncFromRows]
)
const resetToDefault = useCallback(() => {
const initialRows = objectToRows(DEFAULT_PRICES)
setRows(initialRows)
setJsonText(JSON.stringify(DEFAULT_PRICES, null, 2))
setJsonError('')
setNextRowId(initialRows.length + 1)
}, [])
const handleCopyJson = useCallback(async () => {
try {
await navigator.clipboard.writeText(jsonText)
toast.success(t('Copied to clipboard'))
} catch {
toast.error(t('Failed to copy'))
}
}, [jsonText, t])
const handleSave = useCallback(async () => {
if (editMode === 'json' && jsonError) {
toast.error(t('Please fix JSON errors before saving'))
return
}
await updateOption.mutateAsync({
key: OPTION_KEY,
value: JSON.stringify(currentPrices),
})
}, [currentPrices, editMode, jsonError, t, updateOption])
const toggleEditMode = useCallback(() => {
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
return (
<div className='space-y-4'>
<Alert>
<AlertDescription className='space-y-1 text-sm'>
<div>
{t(
'Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.'
)}
</div>
<div>
<span className='font-medium'>{t('Format')}:</span>{' '}
<code className='bg-muted rounded px-1 py-0.5 text-xs'>
web_search_preview
</code>{' '}
{t('is the default price; ')}
<code className='bg-muted rounded px-1 py-0.5 text-xs'>
web_search_preview:gpt-4o*
</code>{' '}
{t('overrides for matching model prefix.')}
</div>
</AlertDescription>
</Alert>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
{editMode === 'visual' ? (
<>
<Button variant='outline' size='sm' onClick={addRow}>
<Plus className='mr-2 h-4 w-4' />
{t('Add')}
</Button>
<Button variant='ghost' size='sm' onClick={resetToDefault}>
{t('Restore defaults')}
</Button>
</>
) : (
<>
<Button variant='ghost' size='sm' onClick={handleCopyJson}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button variant='ghost' size='sm' onClick={resetToDefault}>
{t('Restore defaults')}
</Button>
</>
)}
</div>
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
<Code2 className='mr-2 h-4 w-4' />
{t('Switch to JSON')}
</>
) : (
<>
<Eye className='mr-2 h-4 w-4' />
{t('Switch to Visual')}
</>
)}
</Button>
</div>
{editMode === 'visual' ? (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('Tool identifier')}</TableHead>
<TableHead className='w-[200px]'>
{t('Price ($/1K calls)')}
</TableHead>
<TableHead className='w-[80px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className='text-muted-foreground py-8 text-center'
>
{t('No tools configured')}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Input
value={row.key}
placeholder='web_search_preview:gpt-4o*'
onChange={(e) =>
updateRow(row.id, 'key', e.target.value)
}
/>
</TableCell>
<TableCell>
<Input
type='number'
min={0}
step={0.5}
value={row.price}
onChange={(e) =>
updateRow(
row.id,
'price',
Number(e.target.value) || 0
)
}
/>
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
onClick={() => removeRow(row.id)}
aria-label={t('Delete')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : (
<div className='space-y-2'>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
className='font-mono text-sm'
rows={12}
spellCheck={false}
/>
{jsonError && <p className='text-destructive text-sm'>{jsonError}</p>}
</div>
)}
<div className='flex justify-end'>
<Button
onClick={handleSave}
disabled={
updateOption.isPending || (editMode === 'json' && !!jsonError)
}
>
{t('Save tool prices')}
</Button>
</div>
</div>
)
})
@@ -0,0 +1,338 @@
import { useMemo } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { StatusBadge } from '@/components/status-badge'
import type { RatioType } from '../types'
import {
getOrderedRatioTypes,
getPreferredSyncField,
getSyncFieldLabel,
isSelectableUpstreamValue,
type ModelRow,
type ResolutionsMap,
} from './upstream-ratio-sync-helpers'
export function useUpstreamRatioSyncColumns(
upstreamNames: string[],
resolutions: ResolutionsMap,
ratioTypeFilter: string,
isDisabled: boolean,
onSelectValue: (
model: string,
ratioType: RatioType,
value: number | string,
sourceName: string
) => void,
onUnselectValue: (model: string, ratioType: RatioType) => void,
onBulkSelect: (upstreamName: string, rows: ModelRow[]) => void,
onBulkUnselect: (upstreamName: string, rows: ModelRow[]) => void
): ColumnDef<ModelRow>[] {
const { t } = useTranslation()
return useMemo<ColumnDef<ModelRow>[]>(() => {
const baseColumns: ColumnDef<ModelRow>[] = [
{
accessorKey: 'model',
header: t('Model'),
cell: ({ row }) => {
const model = row.original.model
return (
<div className='flex min-w-[180px] items-center gap-2'>
<span className='font-medium'>{model}</span>
{row.original.billingConflict && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className='h-3.5 w-3.5 shrink-0 text-amber-500' />
</TooltipTrigger>
<TooltipContent>
<p>
{t(
'This model has both fixed price and ratio billing conflicts'
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
},
},
{
id: 'current',
header: t('Current Price'),
cell: ({ row }) => {
const fields = getOrderedRatioTypes(
row.original.ratioTypes,
ratioTypeFilter
)
return (
<div className='flex min-w-[260px] flex-col gap-2'>
{fields.map((ratioType) => {
const current = row.original.ratioTypes[ratioType]?.current
return (
<div
key={ratioType}
className='flex min-w-0 flex-wrap items-center gap-2'
>
<StatusBadge
label={getSyncFieldLabel(ratioType, t)}
autoColor={ratioType}
size='sm'
copyable={false}
/>
{current === null || current === undefined ? (
<StatusBadge
label={t('Not Set')}
variant='neutral'
size='sm'
copyable={false}
/>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<StatusBadge
label={String(current)}
variant='info'
size='sm'
className='max-w-[200px] truncate'
/>
</TooltipTrigger>
<TooltipContent>
<p className='max-w-xs text-xs break-all'>
{String(current)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
})}
</div>
)
},
},
]
const upstreamColumns: ColumnDef<ModelRow>[] = upstreamNames.map(
(upstreamName) => ({
id: `upstream_${upstreamName}`,
header: ({ table }) => {
const rows = table.getFilteredRowModel().rows.map((r) => r.original)
let selectableCount = 0
let selectedCount = 0
rows.forEach((row) => {
getOrderedRatioTypes(row.ratioTypes, ratioTypeFilter).forEach(
(ratioType) => {
const upstreamVal =
row.ratioTypes[ratioType]?.upstreams?.[upstreamName]
const preferredField = getPreferredSyncField(
row.ratioTypes,
ratioType,
upstreamName
)
if (
preferredField === ratioType &&
isSelectableUpstreamValue(upstreamVal)
) {
selectableCount++
if (resolutions[row.model]?.[ratioType] === upstreamVal) {
selectedCount++
}
}
}
)
})
const allSelected =
selectableCount > 0 && selectedCount === selectableCount
const someSelected =
selectedCount > 0 && selectedCount < selectableCount
let checkboxState: boolean | 'indeterminate' = false
if (allSelected) checkboxState = true
else if (someSelected) checkboxState = 'indeterminate'
return (
<div className='flex items-center gap-2'>
{selectableCount > 0 && (
<Checkbox
checked={checkboxState}
disabled={isDisabled}
onCheckedChange={(checked) => {
if (checked) {
onBulkSelect(upstreamName, rows)
} else {
onBulkUnselect(upstreamName, rows)
}
}}
/>
)}
<span className='font-medium'>{upstreamName}</span>
</div>
)
},
cell: ({ row }) => {
const fields = getOrderedRatioTypes(
row.original.ratioTypes,
ratioTypeFilter
).filter(
(ratioType) =>
getPreferredSyncField(
row.original.ratioTypes,
ratioType,
upstreamName
) === ratioType
)
return (
<div className='flex min-w-[280px] flex-col gap-2'>
{fields.map((ratioType) => {
const diff = row.original.ratioTypes[ratioType]
const upstreamVal = diff?.upstreams?.[upstreamName]
const isConfident = diff?.confidence?.[upstreamName] !== false
return (
<div
key={ratioType}
className='flex min-w-0 items-start gap-2'
>
<StatusBadge
label={getSyncFieldLabel(ratioType, t)}
autoColor={ratioType}
size='sm'
copyable={false}
className='shrink-0'
/>
<div className='min-w-0 flex-1'>
{renderUpstreamValue({
upstreamVal,
isConfident,
isSelected:
resolutions[row.original.model]?.[ratioType] ===
upstreamVal,
isDisabled,
t,
onSelect: () =>
onSelectValue(
row.original.model,
ratioType,
upstreamVal as number | string,
upstreamName
),
onUnselect: () =>
onUnselectValue(row.original.model, ratioType),
})}
</div>
</div>
)
})}
</div>
)
},
})
)
return [...baseColumns, ...upstreamColumns]
}, [
upstreamNames,
resolutions,
ratioTypeFilter,
isDisabled,
onSelectValue,
onUnselectValue,
onBulkSelect,
onBulkUnselect,
t,
])
}
type RenderUpstreamValueArgs = {
upstreamVal: number | string | 'same' | null | undefined
isConfident: boolean
isSelected: boolean
isDisabled: boolean
t: (key: string) => string
onSelect: () => void
onUnselect: () => void
}
function renderUpstreamValue(args: RenderUpstreamValueArgs) {
const { upstreamVal, isConfident, isSelected, isDisabled, t } = args
if (upstreamVal === null || upstreamVal === undefined) {
return (
<StatusBadge
label={t('Not Set')}
variant='neutral'
size='sm'
copyable={false}
/>
)
}
if (upstreamVal === 'same') {
return (
<StatusBadge
label={t('Same as Local')}
variant='info'
size='sm'
copyable={false}
/>
)
}
const text = String(upstreamVal)
return (
<div className='flex min-w-0 items-center gap-2'>
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={(checked) => {
if (checked) {
args.onSelect()
} else {
args.onUnselect()
}
}}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-block max-w-[240px] cursor-default truncate font-mono text-sm'>
{text}
</span>
</TooltipTrigger>
<TooltipContent>
<p className='max-w-xs text-xs break-all'>{text}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isConfident && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className='h-3.5 w-3.5 shrink-0 text-amber-500' />
</TooltipTrigger>
<TooltipContent>
<p>{t('This data may be unreliable, use with caution')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)
}
@@ -0,0 +1,84 @@
import type { RatioType } from '../types'
import { RATIO_TYPE_OPTIONS } from './constants'
export type RatioDifferenceEntry = {
current: number | string | null
upstreams: Record<string, number | string | 'same'>
confidence: Record<string, boolean>
}
export type ModelRow = {
key: string
model: string
ratioTypes: Partial<Record<RatioType, RatioDifferenceEntry>>
billingConflict: boolean
}
export type ResolutionsMap = Record<string, Record<string, number | string>>
export const RATIO_SYNC_FIELDS: RatioType[] = [
'model_ratio',
'completion_ratio',
'cache_ratio',
'create_cache_ratio',
'image_ratio',
'audio_ratio',
'audio_completion_ratio',
]
export const SYNC_FIELD_ORDER: RatioType[] = [
...RATIO_SYNC_FIELDS,
'model_price',
'billing_mode',
'billing_expr',
]
export const NUMERIC_SYNC_FIELDS = new Set<string>([
...RATIO_SYNC_FIELDS,
'model_price',
])
export function getSyncFieldLabel(
ratioType: string,
t: (key: string) => string
): string {
const opt = RATIO_TYPE_OPTIONS.find((o) => o.value === ratioType)
if (opt) return t(opt.label)
return ratioType
}
export function getOrderedRatioTypes(
ratioTypes: Partial<Record<RatioType, RatioDifferenceEntry>>,
filter?: string
): RatioType[] {
const keys = Object.keys(ratioTypes) as RatioType[]
const ordered = [
...SYNC_FIELD_ORDER.filter((f) => keys.includes(f)),
...keys.filter((f) => !SYNC_FIELD_ORDER.includes(f)),
]
if (!filter || filter === '__all__') return ordered
return ordered.filter((f) => f === filter)
}
export function getPreferredSyncField(
ratioTypes: Partial<Record<RatioType, RatioDifferenceEntry>>,
ratioType: RatioType,
sourceName: string
): RatioType {
const exprValue = ratioTypes.billing_expr?.upstreams?.[sourceName]
if (
ratioType !== 'billing_expr' &&
exprValue !== null &&
exprValue !== undefined &&
exprValue !== 'same'
) {
return 'billing_expr'
}
return ratioType
}
export function isSelectableUpstreamValue(
value: number | string | 'same' | null | undefined
): boolean {
return value !== null && value !== undefined && value !== 'same'
}
@@ -0,0 +1,284 @@
import { useCallback, useMemo, useState } from 'react'
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Loader2, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import type { DifferencesMap, RatioType } from '../types'
import { RATIO_TYPE_OPTIONS } from './constants'
import { useUpstreamRatioSyncColumns } from './upstream-ratio-sync-columns'
import {
getOrderedRatioTypes,
getPreferredSyncField,
isSelectableUpstreamValue,
RATIO_SYNC_FIELDS,
type ModelRow,
type ResolutionsMap,
} from './upstream-ratio-sync-helpers'
type UpstreamRatioSyncTableProps = {
differences: DifferencesMap
resolutions: ResolutionsMap
isDisabled: boolean
isSyncing: boolean
onSelectValue: (
model: string,
ratioType: RatioType,
value: number | string,
sourceName: string
) => void
onUnselectValue: (model: string, ratioType: RatioType) => void
}
export function UpstreamRatioSyncTable({
differences,
resolutions,
isDisabled,
isSyncing,
onSelectValue,
onUnselectValue,
}: UpstreamRatioSyncTableProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [ratioTypeFilter, setRatioTypeFilter] = useState<string>('')
const dataSource = useMemo<ModelRow[]>(() => {
return Object.entries(differences).map(([model, ratioTypes]) => {
const hasPrice = 'model_price' in ratioTypes
const hasOtherRatio = RATIO_SYNC_FIELDS.some((rt) => rt in ratioTypes)
return {
key: model,
model,
ratioTypes,
billingConflict: hasPrice && hasOtherRatio,
}
})
}, [differences])
const filteredData = useMemo(() => {
let data = dataSource
if (search.trim()) {
const lower = search.toLowerCase()
data = data.filter((row) => row.model.toLowerCase().includes(lower))
}
if (ratioTypeFilter && ratioTypeFilter !== '__all__') {
data = data.filter((row) => ratioTypeFilter in row.ratioTypes)
}
return data
}, [dataSource, search, ratioTypeFilter])
const upstreamNames = useMemo(() => {
const set = new Set<string>()
filteredData.forEach((row) => {
getOrderedRatioTypes(row.ratioTypes, ratioTypeFilter).forEach(
(ratioType) => {
Object.keys(row.ratioTypes[ratioType]?.upstreams || {}).forEach(
(name) => set.add(name)
)
}
)
})
return Array.from(set)
}, [filteredData, ratioTypeFilter])
const handleBulkSelect = useCallback(
(upstream: string, rows: ModelRow[]) => {
rows.forEach((row) => {
getOrderedRatioTypes(row.ratioTypes, ratioTypeFilter).forEach(
(ratioType) => {
const upstreamVal = row.ratioTypes[ratioType]?.upstreams?.[upstream]
const preferredField = getPreferredSyncField(
row.ratioTypes,
ratioType,
upstream
)
if (
preferredField === ratioType &&
isSelectableUpstreamValue(upstreamVal)
) {
onSelectValue(
row.model,
ratioType,
upstreamVal as number | string,
upstream
)
}
}
)
})
},
[ratioTypeFilter, onSelectValue]
)
const handleBulkUnselect = useCallback(
(upstream: string, rows: ModelRow[]) => {
rows.forEach((row) => {
getOrderedRatioTypes(row.ratioTypes, ratioTypeFilter).forEach(
(ratioType) => {
if (
row.ratioTypes[ratioType]?.upstreams?.[upstream] !== undefined
) {
onUnselectValue(row.model, ratioType)
}
}
)
})
},
[ratioTypeFilter, onUnselectValue]
)
const columns = useUpstreamRatioSyncColumns(
upstreamNames,
resolutions,
ratioTypeFilter,
isDisabled,
onSelectValue,
onUnselectValue,
handleBulkSelect,
handleBulkUnselect
)
const table = useReactTable({
data: filteredData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.key,
initialState: {
pagination: { pageSize: 10 },
},
})
if (dataSource.length === 0) {
if (isSyncing) {
return (
<div className='flex h-64 flex-col items-center justify-center gap-3 rounded-md border'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching upstream prices...')}
</p>
</div>
)
}
return (
<div className='flex h-64 items-center justify-center rounded-md border'>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('No upstream price differences found')}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Select sync channels to compare prices')}
</p>
</div>
</div>
)
}
return (
<div className='space-y-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search model name...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={isDisabled}
className='ps-8'
/>
</div>
<Select
value={ratioTypeFilter}
onValueChange={setRatioTypeFilter}
disabled={isDisabled}
>
<SelectTrigger className='w-full sm:w-56'>
<SelectValue placeholder={t('Filter by price field')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>{t('All Types')}</SelectItem>
{RATIO_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='overflow-x-auto'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className='align-top'>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className='align-top'>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='align-top'>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No results found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
)
}
@@ -0,0 +1,552 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CheckSquare, RefreshCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
fetchUpstreamRatios,
getUpstreamChannels,
updateSystemOption,
} from '../api'
import type {
DifferencesMap,
RatioType,
UpstreamChannel,
UpstreamConfig,
} from '../types'
import { ChannelSelectorDialog } from './channel-selector-dialog'
import {
ConflictConfirmDialog,
type ConflictItem,
} from './conflict-confirm-dialog'
import {
DEFAULT_ENDPOINT,
MODELS_DEV_PRESET_ENDPOINT,
MODELS_DEV_PRESET_ID,
OFFICIAL_CHANNEL_ENDPOINT,
OFFICIAL_CHANNEL_ID,
OPENROUTER_CHANNEL_TYPE,
OPENROUTER_ENDPOINT,
} from './constants'
import {
NUMERIC_SYNC_FIELDS,
RATIO_SYNC_FIELDS,
getPreferredSyncField,
type ResolutionsMap,
} from './upstream-ratio-sync-helpers'
import { UpstreamRatioSyncTable } from './upstream-ratio-sync-table'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type UpstreamRatioSyncProps = {
modelRatios: {
ModelPrice: string
ModelRatio: string
CompletionRatio: string
CacheRatio: string
CreateCacheRatio: string
ImageRatio: string
AudioRatio: string
AudioCompletionRatio: string
'billing_setting.billing_mode': string
'billing_setting.billing_expr': string
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The two synthesized presets always carry stable negative IDs assigned by
// `controller/ratio_sync.go`; matching by ID alone is sufficient and avoids
// fragile name/base_url comparisons.
function getDefaultEndpointForChannel(channel: UpstreamChannel): string {
if (channel.id === MODELS_DEV_PRESET_ID) return MODELS_DEV_PRESET_ENDPOINT
if (channel.id === OFFICIAL_CHANNEL_ID) return OFFICIAL_CHANNEL_ENDPOINT
if (channel.type === OPENROUTER_CHANNEL_TYPE) return OPENROUTER_ENDPOINT
return DEFAULT_ENDPOINT
}
function getBillingCategory(ratioType: string): 'price' | 'ratio' | 'tiered' {
if (ratioType === 'model_price') return 'price'
if (ratioType === 'billing_mode' || ratioType === 'billing_expr')
return 'tiered'
return 'ratio'
}
function optionKeyBySyncField(ratioType: string): string {
const explicit: Record<string, string> = {
billing_mode: 'billing_setting.billing_mode',
billing_expr: 'billing_setting.billing_expr',
}
if (explicit[ratioType]) return explicit[ratioType]
return ratioType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
}
function parseJsonRecord<T>(raw: string | undefined | null): Record<string, T> {
try {
return JSON.parse(raw || '{}') as Record<string, T>
} catch {
return {}
}
}
function deleteResolutionField(
res: ResolutionsMap,
model: string,
ratioType: string
): ResolutionsMap {
if (!res[model]) return res
const newModelRes = { ...res[model] }
delete newModelRes[ratioType]
if (ratioType === 'billing_expr') delete newModelRes['billing_mode']
if (ratioType === 'billing_mode') delete newModelRes['billing_expr']
const next = { ...res }
if (Object.keys(newModelRes).length === 0) {
delete next[model]
} else {
next[model] = newModelRes
}
return next
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [channelDialogOpen, setChannelDialogOpen] = useState(false)
const [conflictDialogOpen, setConflictDialogOpen] = useState(false)
const [selectedChannelIds, setSelectedChannelIds] = useState<number[]>([])
const [channelEndpoints, setChannelEndpoints] = useState<
Record<number, string>
>({})
const [differences, setDifferences] = useState<DifferencesMap>({})
const [resolutions, setResolutions] = useState<ResolutionsMap>({})
const [conflictItems, setConflictItems] = useState<ConflictItem[]>([])
const [confirmLoading, setConfirmLoading] = useState(false)
const { data: channelsData } = useQuery({
queryKey: ['upstream-channels'],
queryFn: getUpstreamChannels,
enabled: channelDialogOpen,
})
// Memoize the channels list so the effect below only re-runs when the query
// data actually changes, instead of on every render (the `|| []` fallback
// would otherwise produce a new array reference each render).
const channels = useMemo(() => channelsData?.data ?? [], [channelsData?.data])
useEffect(() => {
if (channels.length === 0) return
setChannelEndpoints((prev) => {
let mutated = false
const next = { ...prev }
for (const channel of channels) {
if (!next[channel.id]) {
next[channel.id] = getDefaultEndpointForChannel(channel)
mutated = true
}
}
return mutated ? next : prev
})
}, [channels])
const fetchMutation = useMutation({
mutationFn: fetchUpstreamRatios,
onSuccess: (data) => {
if (!data.success) {
toast.error(data.message || t('Failed to fetch upstream prices'))
return
}
const { differences: diffs, test_results } = data.data
const errorResults = test_results.filter((r) => r.status === 'error')
if (errorResults.length > 0) {
const errorMsg = errorResults
.map((r) => `${r.name}: ${r.error}`)
.join(', ')
toast.warning(t('Some channels failed: {{errorMsg}}', { errorMsg }))
}
setDifferences(diffs)
setResolutions({})
if (Object.keys(diffs).length === 0) {
toast.success(t('No price differences found'))
} else {
toast.success(t('Upstream prices fetched successfully'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to fetch upstream prices'))
},
})
const { mutate: syncMutate, isPending: isSyncPending } = useMutation({
mutationFn: async (updates: Array<{ key: string; value: string }>) => {
for (const update of updates) {
await updateSystemOption(update)
}
},
onSuccess: () => {
toast.success(t('Prices synced successfully'))
queryClient.invalidateQueries({ queryKey: ['system-options'] })
setDifferences((prevDiffs) => {
const newDiffs = { ...prevDiffs }
Object.entries(resolutions).forEach(([model, ratios]) => {
Object.keys(ratios).forEach((ratioType) => {
if (newDiffs[model]?.[ratioType as RatioType]) {
delete newDiffs[model][ratioType as RatioType]
if (Object.keys(newDiffs[model]).length === 0) {
delete newDiffs[model]
}
}
})
})
return newDiffs
})
setResolutions({})
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to sync prices'))
},
})
const handleOpenChannelDialog = () => {
setChannelDialogOpen(true)
}
const handleConfirmChannelSelection = (selectedIds: number[]) => {
const selectedChannels = channels.filter((ch) =>
selectedIds.includes(ch.id)
)
if (selectedChannels.length === 0) {
toast.warning(t('Please select at least one channel'))
return
}
const upstreams: UpstreamConfig[] = selectedChannels.map((ch) => ({
id: ch.id,
name: ch.name,
base_url: ch.base_url,
endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT,
}))
fetchMutation.mutate({ upstreams, timeout: 10 })
}
const handleSelectValue = useCallback(
(
model: string,
ratioType: RatioType,
value: number | string,
sourceName: string
) => {
const modelDiffs = differences[model]
// Prefer billing_expr over individual ratio fields when available
const preferredType = sourceName
? getPreferredSyncField(modelDiffs || {}, ratioType, sourceName)
: ratioType
const preferredValue =
preferredType === ratioType
? value
: (modelDiffs?.[preferredType]?.upstreams?.[sourceName] ?? value)
const finalType = preferredType
const finalValue = preferredValue as number | string
const category = getBillingCategory(finalType)
setResolutions((prev) => {
const newModelRes = { ...(prev[model] || {}) }
// Clear conflicting categories
Object.keys(newModelRes).forEach((rt) => {
if (
category !== 'tiered' &&
getBillingCategory(rt) !== 'tiered' &&
getBillingCategory(rt) !== category
) {
delete newModelRes[rt]
}
})
newModelRes[finalType] = finalValue
// When selecting a tiered field, auto-populate paired fields from the same source
if (category === 'tiered' && sourceName && modelDiffs) {
const modeVal = modelDiffs.billing_mode?.upstreams?.[sourceName]
const exprVal = modelDiffs.billing_expr?.upstreams?.[sourceName]
if (modeVal !== undefined && modeVal !== null && modeVal !== 'same') {
newModelRes['billing_mode'] = modeVal
} else if (finalType === 'billing_expr') {
newModelRes['billing_mode'] = 'tiered_expr'
}
if (exprVal !== undefined && exprVal !== null && exprVal !== 'same') {
newModelRes['billing_expr'] = exprVal
}
}
return { ...prev, [model]: newModelRes }
})
},
[differences]
)
const handleUnselectValue = useCallback(
(model: string, ratioType: RatioType) => {
setResolutions((prev) => deleteResolutionField(prev, model, ratioType))
},
[]
)
const parsedRatios = useMemo(() => {
return {
ModelRatio: parseJsonRecord<number>(modelRatios.ModelRatio),
CompletionRatio: parseJsonRecord<number>(modelRatios.CompletionRatio),
CacheRatio: parseJsonRecord<number>(modelRatios.CacheRatio),
CreateCacheRatio: parseJsonRecord<number>(modelRatios.CreateCacheRatio),
ImageRatio: parseJsonRecord<number>(modelRatios.ImageRatio),
AudioRatio: parseJsonRecord<number>(modelRatios.AudioRatio),
AudioCompletionRatio: parseJsonRecord<number>(
modelRatios.AudioCompletionRatio
),
ModelPrice: parseJsonRecord<number>(modelRatios.ModelPrice),
'billing_setting.billing_mode': parseJsonRecord<string>(
modelRatios['billing_setting.billing_mode']
),
'billing_setting.billing_expr': parseJsonRecord<string>(
modelRatios['billing_setting.billing_expr']
),
}
}, [modelRatios])
type ParsedRatios = typeof parsedRatios
const getLocalBillingCategory = (
model: string,
currentRatios: ParsedRatios
): 'price' | 'ratio' | null => {
if (currentRatios.ModelPrice[model] !== undefined) return 'price'
if (
currentRatios.ModelRatio[model] !== undefined ||
currentRatios.CompletionRatio[model] !== undefined ||
currentRatios.CacheRatio[model] !== undefined ||
currentRatios.CreateCacheRatio[model] !== undefined ||
currentRatios.ImageRatio[model] !== undefined ||
currentRatios.AudioRatio[model] !== undefined ||
currentRatios.AudioCompletionRatio[model] !== undefined
)
return 'ratio'
return null
}
const performSync = useCallback(
async (currentRatios: ParsedRatios): Promise<boolean> => {
const finalRatios: Record<string, Record<string, number | string>> = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
CreateCacheRatio: { ...currentRatios.CreateCacheRatio },
ImageRatio: { ...currentRatios.ImageRatio },
AudioRatio: { ...currentRatios.AudioRatio },
AudioCompletionRatio: { ...currentRatios.AudioCompletionRatio },
ModelPrice: { ...currentRatios.ModelPrice },
'billing_setting.billing_mode': {
...currentRatios['billing_setting.billing_mode'],
},
'billing_setting.billing_expr': {
...currentRatios['billing_setting.billing_expr'],
},
}
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios)
const hasPrice = selectedTypes.includes('model_price')
const hasRatio = selectedTypes.some((rt) =>
RATIO_SYNC_FIELDS.includes(rt as RatioType)
)
if (hasPrice) {
delete finalRatios.ModelRatio[model]
delete finalRatios.CompletionRatio[model]
delete finalRatios.CacheRatio[model]
delete finalRatios.CreateCacheRatio[model]
delete finalRatios.ImageRatio[model]
delete finalRatios.AudioRatio[model]
delete finalRatios.AudioCompletionRatio[model]
}
if (hasRatio) {
delete finalRatios.ModelPrice[model]
}
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = optionKeyBySyncField(ratioType)
finalRatios[optionKey][model] = NUMERIC_SYNC_FIELDS.has(ratioType)
? Number(value)
: value
})
})
const updates = Object.entries(finalRatios).map(([key, value]) => ({
key,
value: JSON.stringify(value, null, 2),
}))
return new Promise<boolean>((resolve) => {
syncMutate(updates, {
onSuccess: () => resolve(true),
onError: () => resolve(false),
})
})
},
[resolutions, syncMutate]
)
const findSourceChannel = (
model: string,
ratioType: RatioType,
value: number | string
): string => {
const upMap = differences[model]?.[ratioType]?.upstreams
if (!upMap) return 'Unknown'
const entry = Object.entries(upMap).find(([, v]) => v === value)
return entry ? entry[0] : 'Unknown'
}
const handleApplySync = () => {
const currentRatios = parsedRatios
const conflicts: ConflictItem[] = []
const fixedPriceLabel = t('Fixed price')
const modelRatioLabel = t('Model ratio')
const completionRatioLabel = t('Completion ratio')
Object.entries(resolutions).forEach(([model, ratios]) => {
const localCat = getLocalBillingCategory(model, currentRatios)
const selectedTypes = Object.keys(ratios)
let newCat: 'price' | 'ratio' | 'tiered'
if ('model_price' in ratios) {
newCat = 'price'
} else if (RATIO_SYNC_FIELDS.some((rt) => selectedTypes.includes(rt))) {
newCat = 'ratio'
} else {
newCat = 'tiered'
}
if (localCat && newCat !== 'tiered' && localCat !== newCat) {
const currentDesc =
localCat === 'price'
? `${fixedPriceLabel}: ${currentRatios.ModelPrice[model]}`
: `${modelRatioLabel}: ${currentRatios.ModelRatio[model] ?? '-'}\n${completionRatioLabel}: ${currentRatios.CompletionRatio[model] ?? '-'}`
const newDesc =
newCat === 'price'
? `${fixedPriceLabel}: ${ratios.model_price}`
: `${modelRatioLabel}: ${ratios.model_ratio ?? '-'}\n${completionRatioLabel}: ${ratios.completion_ratio ?? '-'}`
const channelNames = selectedTypes
.map((rt) => findSourceChannel(model, rt as RatioType, ratios[rt]))
.filter((v, idx, arr) => arr.indexOf(v) === idx)
.join(', ')
conflicts.push({
channel: channelNames,
model,
current: currentDesc,
newVal: newDesc,
})
}
})
if (conflicts.length > 0) {
setConflictItems(conflicts)
setConflictDialogOpen(true)
return
}
toast.info(t('Syncing prices, please wait...'))
performSync(currentRatios)
}
const handleConfirmConflict = async () => {
setConfirmLoading(true)
try {
const success = await performSync(parsedRatios)
if (success) {
setConflictDialogOpen(false)
}
} finally {
setConfirmLoading(false)
}
}
const hasSelections = Object.keys(resolutions).length > 0
const isLoading = fetchMutation.isPending || isSyncPending || confirmLoading
return (
<div className='space-y-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Button onClick={handleOpenChannelDialog} disabled={isLoading}>
<RefreshCcw className='mr-2 h-4 w-4' />
{t('Select Sync Channels')}
</Button>
<Button
variant='secondary'
onClick={handleApplySync}
disabled={!hasSelections || isLoading}
>
{(isSyncPending || confirmLoading) && (
<span className='mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent' />
)}
<CheckSquare className='mr-2 h-4 w-4' />
{t('Apply Sync')}
</Button>
</div>
</div>
<UpstreamRatioSyncTable
differences={differences}
resolutions={resolutions}
isDisabled={isLoading}
isSyncing={fetchMutation.isPending}
onSelectValue={handleSelectValue}
onUnselectValue={handleUnselectValue}
/>
<ChannelSelectorDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channels={channels}
selectedChannelIds={selectedChannelIds}
onSelectedChannelIdsChange={setSelectedChannelIds}
channelEndpoints={channelEndpoints}
onChannelEndpointsChange={setChannelEndpoints}
onConfirm={handleConfirmChannelSelection}
/>
<ConflictConfirmDialog
open={conflictDialogOpen}
onOpenChange={setConflictDialogOpen}
conflicts={conflictItems}
onConfirm={handleConfirmConflict}
isLoading={confirmLoading}
/>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
export function formatJsonForTextarea(value: string) {
if (!value || !value.trim()) {
return ''
}
try {
const parsed = JSON.parse(value)
return JSON.stringify(parsed, null, 2)
} catch {
return value
}
}
export function normalizeJsonString(value: string) {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
try {
const parsed = JSON.parse(trimmed)
return JSON.stringify(parsed)
} catch {
return trimmed
}
}
type JsonValidationOptions = {
allowEmpty?: boolean
predicate?: (value: unknown) => boolean
predicateMessage?: string
}
function extractErrorPosition(
error: unknown,
jsonString: string
): { line?: number; column?: number; position?: number } {
if (!(error instanceof Error)) return {}
const message = error.message
// Format 1: "Unexpected token } in JSON at position 15"
const positionMatch = message.match(/at position (\d+)/i)
if (positionMatch) {
const position = parseInt(positionMatch[1], 10)
const lines = jsonString.substring(0, position).split('\n')
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
position,
}
}
// Format 2: "JSON.parse: ... at line 2 column 3"
const lineColMatch = message.match(/at line (\d+) column (\d+)/i)
if (lineColMatch) {
return {
line: parseInt(lineColMatch[1], 10),
column: parseInt(lineColMatch[2], 10),
}
}
return {}
}
function formatErrorMessage(error: unknown, jsonString: string): string {
if (!(error instanceof Error)) return 'Invalid JSON'
const position = extractErrorPosition(error, jsonString)
const message = error.message
// Check if it's a "missing comma" type error
const isMissingCommaError =
message.includes("Expected ','") ||
message.includes('Expected property name') ||
message.includes('Unexpected string')
if (position.line && position.column) {
let hint = ''
if (isMissingCommaError && position.line > 1) {
hint = ` (check line ${position.line - 1} for missing comma)`
}
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
}
if (position.position !== undefined) {
return `Error at position ${position.position}: ${message}`
}
return message
}
export function validateJsonString(
value: string,
options: JsonValidationOptions = {}
) {
const { allowEmpty = true, predicate, predicateMessage } = options
const trimmed = value.trim()
if (!trimmed) {
return {
valid: allowEmpty,
message: allowEmpty ? undefined : 'Value is required',
}
}
try {
const parsed = JSON.parse(trimmed)
if (predicate && !predicate(parsed)) {
return {
valid: false,
message: predicateMessage || 'JSON structure is invalid',
}
}
return { valid: true }
} catch (error: unknown) {
return {
valid: false,
message: formatErrorMessage(error, trimmed),
}
}
}