From ed1408c007c072926edf2f8dbae614757c872a54 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 09:03:09 +0800 Subject: [PATCH 01/10] v1.0.4 --- README.en.md | 4 + README.fr.md | 4 + README.ja.md | 4 + README.md | 13 +- README.zh_CN.md | 4 + README.zh_TW.md | 4 + .../maintenance/header-navigation-section.tsx | 58 ++++++- .../rankings-visibility-section.tsx | 160 ------------------ .../system-settings/site/section-registry.tsx | 20 +-- .../system-settings/site/$section.tsx | 7 + 10 files changed, 89 insertions(+), 189 deletions(-) delete mode 100644 web/default/src/features/system-settings/maintenance/rankings-visibility-section.tsx diff --git a/README.en.md b/README.en.md index 994ae131..662e227a 100644 --- a/README.en.md +++ b/README.en.md @@ -476,6 +476,7 @@ A task rate-card can match prices by request parameters: | Remote database | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 | | Cache | In-memory cache for single-node deployments; Redis recommended for multi-node deployments | | Frontend build | Bun workspace; keep `web/package.json` and `web/bun.lock` | +| Source build | Use the Go version declared in `go.mod` (currently Go 1.25.1+) with the repository `go.sum`; after dependency or security updates, run `go mod download`, `go mod verify`, and rebuild | ### Recommended Environment Variables @@ -548,6 +549,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` downloads Go modules during the image build. For host builds or dependency/security updates, keep `go.mod` and `go.sum` committed together, run `go mod download && go mod verify`, then rebuild the binary or image; use `docker build --pull --no-cache -t cscitechtop/max-api:latest .` when base images need to be refreshed. + > [!TIP] > The frontend uses Bun workspace. The build context must keep `web/package.json`, `web/bun.lock`, and `web/default/package.json`; otherwise `catalog:` dependencies cannot be resolved. diff --git a/README.fr.md b/README.fr.md index 1d60fc66..d6b25b8d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -413,6 +413,7 @@ Les modèles vidéo comme Seedance 2.0 peuvent utiliser la résolution, l'entré | Base distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 | | Cache | Mémoire en mono-nœud, Redis recommandé en multi-nœud | | Build frontend | Bun workspace, conserver `web/package.json` et `web/bun.lock` | +| Build source | Utiliser la version de Go déclarée dans `go.mod` (actuellement Go 1.25.1+) avec `go.sum` ; après une mise à jour de dépendances ou de sécurité, exécuter `go mod download`, `go mod verify`, puis reconstruire | ### Variables d'environnement recommandées @@ -477,6 +478,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` télécharge les modules Go pendant la construction de l'image. Pour une construction locale ou après une mise à jour de dépendances / sécurité, conservez `go.mod` et `go.sum` ensemble, exécutez `go mod download && go mod verify`, puis reconstruisez le binaire ou l'image ; utilisez `docker build --pull --no-cache -t cscitechtop/max-api:latest .` lorsque les images de base doivent être rafraîchies. + > [!TIP] > Le frontend utilise Bun workspace. Conservez `web/package.json`, `web/bun.lock` et `web/default/package.json`, sinon les dépendances `catalog:` ne seront pas résolues. diff --git a/README.ja.md b/README.ja.md index b76c4219..3313b9e3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -413,6 +413,7 @@ Seedance 2.0 などの動画モデルでは、解像度や動画入力などの | リモートデータベース | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 | | キャッシュ | 単一ノードはメモリキャッシュ、複数ノードは Redis 推奨 | | フロントエンドビルド | Bun workspace。`web/package.json` と `web/bun.lock` を保持 | +| ソースビルド | `go.mod` に記載された Go バージョン(現在は Go 1.25.1+)とリポジトリ内の `go.sum` を使用。依存関係またはセキュリティ更新後は `go mod download`、`go mod verify` を実行してから再ビルド | ### 推奨環境変数 @@ -485,6 +486,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` はイメージビルド中に Go モジュールをダウンロードします。ホストで直接ビルドする場合、または依存関係 / セキュリティ更新後は、`go.mod` と `go.sum` を必ず一緒にコミットし、`go mod download && go mod verify` を実行してからバイナリまたはイメージを再ビルドしてください。ベースイメージを更新する必要がある場合は `docker build --pull --no-cache -t cscitechtop/max-api:latest .` を使用します。 + > [!TIP] > フロントエンドは Bun workspace を使用します。ビルドコンテキストには `web/package.json`、`web/bun.lock`、`web/default/package.json` を保持してください。そうしないと `catalog:` 依存関係を解決できません。 diff --git a/README.md b/README.md index 92a83ef2..c6612c6a 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,7 @@ Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与 | 远程数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 | | 缓存 | 单机可使用内存缓存,多机部署建议使用 Redis | | 前端构建 | 使用 Bun workspace,需保留 `web/package.json` 与 `web/bun.lock` | +| 源码构建 | 使用仓库 `go.mod` 声明的 Go 版本(当前 Go 1.25.1+)和 `go.sum`;依赖或安全更新后运行 `go mod download`、`go mod verify` 并重新构建 | ### 推荐环境变量 @@ -550,6 +551,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` 会在镜像构建中下载 Go 模块。宿主机直接构建或依赖 / 安全更新后,请保持 `go.mod` 与 `go.sum` 成对提交,先运行 `go mod download && go mod verify`,再重新构建二进制或镜像;需要刷新基础镜像时可使用 `docker build --pull --no-cache -t cscitechtop/max-api:latest .`。 + > [!TIP] > 前端使用 Bun workspace。构建上下文中必须保留 `web/package.json`、`web/bun.lock` 和 `web/default/package.json`,否则 `catalog:` 依赖无法解析。 @@ -696,15 +700,6 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或业务 Agent 如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应源码提供等义务。商业合作、机构合作或其他授权问题,请联系:maxapi@max-api.ai。 ---- - -## 🌟 Star History - -
- -[![Star History Chart](https://api.star-history.com/svg?repos=MAX-API-Next/MAX-API&type=Date)](https://star-history.com/#MAX-API-Next/MAX-API&Date) - -
--- diff --git a/README.zh_CN.md b/README.zh_CN.md index 71b9934c..63d3c6ee 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -476,6 +476,7 @@ Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与 | 远程数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 | | 缓存 | 单机可使用内存缓存,多机部署建议使用 Redis | | 前端构建 | 使用 Bun workspace,需保留 `web/package.json` 与 `web/bun.lock` | +| 源码构建 | 使用仓库 `go.mod` 声明的 Go 版本(当前 Go 1.25.1+)和 `go.sum`;依赖或安全更新后运行 `go mod download`、`go mod verify` 并重新构建 | ### 推荐环境变量 @@ -548,6 +549,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` 会在镜像构建中下载 Go 模块。宿主机直接构建或依赖 / 安全更新后,请保持 `go.mod` 与 `go.sum` 成对提交,先运行 `go mod download && go mod verify`,再重新构建二进制或镜像;需要刷新基础镜像时可使用 `docker build --pull --no-cache -t cscitechtop/max-api:latest .`。 + > [!TIP] > 前端使用 Bun workspace。构建上下文中必须保留 `web/package.json`、`web/bun.lock` 和 `web/default/package.json`,否则 `catalog:` 依赖无法解析。 diff --git a/README.zh_TW.md b/README.zh_TW.md index 7b502ae2..c705ed47 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -476,6 +476,7 @@ Seedance 2.0 等視頻模型可按分辨率、視頻輸入等請求參數參與 | 遠程資料庫 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 | | 緩存 | 單機可使用內存緩存,多機部署建議使用 Redis | | 前端構建 | 使用 Bun workspace,需保留 `web/package.json` 與 `web/bun.lock` | +| 源碼構建 | 使用倉庫 `go.mod` 聲明的 Go 版本(目前 Go 1.25.1+)和 `go.sum`;依賴或安全更新後運行 `go mod download`、`go mod verify` 並重新構建 | ### 推薦環境變量 @@ -548,6 +549,9 @@ cd MAX-API docker build -t cscitechtop/max-api:latest . ``` +> [!NOTE] +> `Dockerfile` 會在鏡像構建中下載 Go 模塊。宿主機直接構建或依賴 / 安全更新後,請保持 `go.mod` 與 `go.sum` 成對提交,先運行 `go mod download && go mod verify`,再重新構建二進制或鏡像;需要刷新基礎鏡像時可使用 `docker build --pull --no-cache -t cscitechtop/max-api:latest .`。 + > [!TIP] > 前端使用 Bun workspace。構建上下文中必須保留 `web/package.json`、`web/bun.lock` 和 `web/default/package.json`,否則 `catalog:` 依賴無法解析。 diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index 87193f21..1e92237b 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -42,14 +42,21 @@ import { import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useResetForm } from '../hooks/use-reset-form' -import { HEADER_NAV_DEFAULT, type HeaderNavModulesConfig } from './config' +import { + HEADER_NAV_DEFAULT, + type HeaderNavAccessConfig, + type HeaderNavModulesConfig, +} from './config' import { useHeaderNavModulesOption } from './use-header-nav-modules-option' +import { useRankingsModuleOption } from './use-rankings-module-option' const headerNavSchema = z.object({ home: z.boolean(), console: z.boolean(), pricingEnabled: z.boolean(), pricingRequireAuth: z.boolean(), + rankingsEnabled: z.boolean(), + rankingsRequireAuth: z.boolean(), docs: z.boolean(), about: z.boolean(), customEnabled: z.boolean(), @@ -61,9 +68,13 @@ type HeaderNavFormValues = z.infer type HeaderNavigationSectionProps = { config: HeaderNavModulesConfig + rankingsConfig: HeaderNavAccessConfig } -const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ +const toFormValues = ( + config: HeaderNavModulesConfig, + rankingsConfig: HeaderNavAccessConfig +): HeaderNavFormValues => ({ home: config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home), console: @@ -78,6 +89,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ config.pricing?.requireAuth === undefined ? HEADER_NAV_DEFAULT.pricing.requireAuth : Boolean(config.pricing.requireAuth), + rankingsEnabled: + rankingsConfig.enabled === undefined + ? HEADER_NAV_DEFAULT.rankings.enabled + : Boolean(rankingsConfig.enabled), + rankingsRequireAuth: + rankingsConfig.requireAuth === undefined + ? HEADER_NAV_DEFAULT.rankings.requireAuth + : Boolean(rankingsConfig.requireAuth), docs: config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), about: @@ -100,10 +119,15 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ export function HeaderNavigationSection({ config, + rankingsConfig, }: HeaderNavigationSectionProps) { const { t } = useTranslation() const headerNavOption = useHeaderNavModulesOption() - const formDefaults = useMemo(() => toFormValues(config), [config]) + const rankingsOption = useRankingsModuleOption() + const formDefaults = useMemo( + () => toFormValues(config, rankingsConfig), + [config, rankingsConfig] + ) const form = useForm({ resolver: zodResolver(headerNavSchema), @@ -131,10 +155,15 @@ export function HeaderNavigationSection({ requireAuth: values.pricingRequireAuth, }, })) + await rankingsOption.updateRankingsModule((current) => ({ + ...current, + enabled: values.rankingsEnabled, + requireAuth: values.rankingsRequireAuth, + })) } const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT)) + form.reset(toFormValues(HEADER_NAV_DEFAULT, HEADER_NAV_DEFAULT.rankings)) } const customEnabled = form.watch('customEnabled') @@ -166,9 +195,9 @@ export function HeaderNavigationSection({ ] const accessModules: Array<{ - enabledKey: keyof HeaderNavFormValues - requireAuthKey: keyof HeaderNavFormValues - requireAuthDependsOn: 'pricingEnabled' + enabledKey: 'pricingEnabled' | 'rankingsEnabled' + requireAuthKey: 'pricingRequireAuth' | 'rankingsRequireAuth' + requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled' title: string description: string requireAuthTitle: string @@ -185,6 +214,19 @@ export function HeaderNavigationSection({ 'Visitors must authenticate before accessing the pricing directory.' ), }, + { + enabledKey: 'rankingsEnabled', + requireAuthKey: 'rankingsRequireAuth', + requireAuthDependsOn: 'rankingsEnabled', + title: t('Rankings'), + description: t( + 'Allow users to open the rankings page and query live ranking data.' + ), + requireAuthTitle: t('Require login to view rankings'), + requireAuthDescription: t( + 'Visitors must authenticate before accessing the rankings page.' + ), + }, ] return ( @@ -194,7 +236,7 @@ export function HeaderNavigationSection({ diff --git a/web/default/src/features/system-settings/maintenance/rankings-visibility-section.tsx b/web/default/src/features/system-settings/maintenance/rankings-visibility-section.tsx deleted file mode 100644 index 4593d116..00000000 --- a/web/default/src/features/system-settings/maintenance/rankings-visibility-section.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* -Copyright (C) 2023-2026 MAX-API-Next - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues -*/ -import { useMemo } 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 { - Form, - FormControl, - FormDescription, - FormField, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { Switch } from '@/components/ui/switch' -import { - SettingsControlChildren, - SettingsControlGroup, - SettingsForm, - SettingsSwitchContent, - SettingsSwitchItem, -} from '../components/settings-form-layout' -import { SettingsPageFormActions } from '../components/settings-page-context' -import { SettingsSection } from '../components/settings-section' -import { useResetForm } from '../hooks/use-reset-form' -import { HEADER_NAV_DEFAULT, type HeaderNavAccessConfig } from './config' -import { useRankingsModuleOption } from './use-rankings-module-option' - -const rankingsVisibilitySchema = z.object({ - rankingsEnabled: z.boolean(), - rankingsRequireAuth: z.boolean(), -}) - -type RankingsVisibilityFormValues = z.infer - -type RankingsVisibilitySectionProps = { - config: HeaderNavAccessConfig -} - -const toFormValues = ( - config: HeaderNavAccessConfig -): RankingsVisibilityFormValues => ({ - rankingsEnabled: Boolean(config.enabled), - rankingsRequireAuth: Boolean(config.requireAuth), -}) - -export function RankingsVisibilitySection({ - config, -}: RankingsVisibilitySectionProps) { - const { t } = useTranslation() - const rankingsOption = useRankingsModuleOption() - const formDefaults = useMemo(() => toFormValues(config), [config]) - - const form = useForm({ - resolver: zodResolver(rankingsVisibilitySchema), - defaultValues: formDefaults, - }) - - useResetForm(form, formDefaults) - - const onSubmit = async (values: RankingsVisibilityFormValues) => { - await rankingsOption.updateRankingsModule((current) => ({ - ...current, - enabled: values.rankingsEnabled, - requireAuth: values.rankingsRequireAuth, - })) - } - - const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT.rankings)) - } - - return ( - -
- - - - - ( - - - {t('Enable rankings page')} - - {t( - 'Allow users to open the rankings page and query live ranking data.' - )} - - - - - - - - )} - /> - - ( - - - - - {t('Require login to view rankings')} - - - {t( - 'Visitors must authenticate before accessing the rankings page.' - )} - - - - - - - - - )} - /> - - -
-
- ) -} diff --git a/web/default/src/features/system-settings/site/section-registry.tsx b/web/default/src/features/system-settings/site/section-registry.tsx index 085d0b99..8abf8574 100644 --- a/web/default/src/features/system-settings/site/section-registry.tsx +++ b/web/default/src/features/system-settings/site/section-registry.tsx @@ -25,7 +25,6 @@ import { } from '../maintenance/config' import { HeaderNavigationSection } from '../maintenance/header-navigation-section' import { NoticeSection } from '../maintenance/notice-section' -import { RankingsVisibilitySection } from '../maintenance/rankings-visibility-section' import { SidebarModulesSection } from '../maintenance/sidebar-modules-section' import type { SiteSettings } from '../types' import { createSectionRegistry } from '../utils/section-registry' @@ -62,23 +61,20 @@ const SITE_SECTIONS = [ ), }, { - id: 'rankings', - titleKey: 'Rankings', + id: 'header-navigation', + titleKey: 'Header navigation', build: (settings: SiteSettings) => { const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules) const rankingsConfig = parseHeaderNavAccessModule( settings.RankingsModule, headerNavConfig.rankings ) - return - }, - }, - { - id: 'header-navigation', - titleKey: 'Header navigation', - build: (settings: SiteSettings) => { - const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules) - return + return ( + + ) }, }, { diff --git a/web/default/src/routes/_authenticated/system-settings/site/$section.tsx b/web/default/src/routes/_authenticated/system-settings/site/$section.tsx index 028d9d02..7d5e3994 100644 --- a/web/default/src/routes/_authenticated/system-settings/site/$section.tsx +++ b/web/default/src/routes/_authenticated/system-settings/site/$section.tsx @@ -27,6 +27,13 @@ export const Route = createFileRoute( '/_authenticated/system-settings/site/$section' )({ beforeLoad: ({ params }) => { + if (params.section === 'rankings') { + throw redirect({ + to: '/system-settings/site/$section', + params: { section: 'header-navigation' }, + }) + } + const validSections = SITE_SECTION_IDS as unknown as string[] if (!validSections.includes(params.section)) { throw redirect({ From fc164ab2dfd22112679aaea81f37201933fb992a Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 10:40:44 +0800 Subject: [PATCH 02/10] v1.0.4 --- README.en.md | 15 ++ README.fr.md | 15 ++ README.ja.md | 15 ++ README.md | 15 ++ README.zh_CN.md | 15 ++ README.zh_TW.md | 15 ++ controller/user.go | 19 ++ .../src/components/model-group-selector.tsx | 205 ++++++++++++++++-- web/default/src/features/playground/api.ts | 6 +- .../components/playground-input.tsx | 18 +- .../src/features/playground/hooks/index.ts | 1 + .../hooks/use-playground-options.ts | 129 +++++++++++ web/default/src/features/playground/index.tsx | 94 ++------ .../src/features/playground/lib/index.ts | 1 + .../lib/playground-option-utils.test.ts | 69 ++++++ .../playground/lib/playground-option-utils.ts | 65 ++++++ .../maintenance/header-navigation-section.tsx | 53 +++-- 17 files changed, 625 insertions(+), 125 deletions(-) create mode 100644 web/default/src/features/playground/hooks/use-playground-options.ts create mode 100644 web/default/src/features/playground/lib/playground-option-utils.test.ts create mode 100644 web/default/src/features/playground/lib/playground-option-utils.ts diff --git a/README.en.md b/README.en.md index 662e227a..800fcddb 100644 --- a/README.en.md +++ b/README.en.md @@ -52,6 +52,8 @@ MAX API is an AI model governance, AgentOps, and application service infrastructure project initiated, maintained, and operated long term by AGI enthusiasts from research institutions and universities. It provides developers, researchers, teams, and organizations with a stable and reusable service layer. The project focuses on the operational problems that appear after AI applications move into real use: more models, frequent upstream API changes, longer Agent call chains, and rising pressure around cost and auditability. MAX API provides a unified access, authentication, routing, billing, observability, and governance layer between applications, Agents, users, organizations, and upstream model providers, helping AI applications run with greater stability and control. +In practice, MAX API is more than a request forwarder. It is an operable gateway for AI-ready applications and Agent workloads, bringing protocol normalization, provider differences, traffic bursts, long streaming responses, large request bodies, multi-node cache, cost audit, and performance observability under one governance boundary. + Ongoing investment areas: - **AI model governance**: continuously tracks model updates, API changes, parameter differences, pricing rules, and task protocols across OpenAI, Azure OpenAI, AWS Bedrock, Vertex AI, Ollama, and domestic platforms such as DeepSeek, Qwen / Alibaba Cloud Model Studio, Zhipu GLM, Kimi, Doubao / Volcano Engine, Tencent Hunyuan, Baidu ERNIE / Qianfan, iFlytek Spark, MiniMax, 01.AI, and SiliconFlow. It also tracks application and multimodal ecosystems such as Dify, RAGFlow, Kling, and Seedance, bringing distributed model capabilities into unified governance through channels, model mapping, protocol conversion, path overrides, and configurable task protocols. @@ -83,6 +85,7 @@ In the AGI application era, MAX API focuses on open AI model governance and AI A - **Channel configuration plane**: reduces misconfiguration risk when adding upstream channels, migrating providers, or maintaining non-standard APIs through capability matrices, form validation, model discovery, and protocol templates. - **Protocol and provider adaptation layer**: continuously tracks official overseas APIs, domestic model platform APIs, and OpenAI-compatible / non-standard interface changes, then normalizes them into stable application-side APIs. - **Cost, quota, and reliability governance**: supports channel routing, weighted distribution, retry, rate limiting, pre-charge, failure refund, expression billing, fixed pricing, task rate-cards, multiplier billing, and usage statistics. +- **Performance and scalability governance**: uses Redis / in-memory cache, model request rate limits, streaming timeouts and large-response buffers, request body limits, disk cache, Pyroscope profiling, and graceful shutdown to support stable single-node and multi-node deployments. - **Organization operations and audit layer**: provides user management, group management, private deployment, data retention, audit, and continuous operations optimization for teams, research institutions, enterprises, and community services. - **Reusable governance templates**: accumulates channel templates, task protocol templates, pricing configuration, deployment practices, and operations experience to reduce onboarding cost for new models, providers, and Agent scenarios. @@ -195,6 +198,17 @@ docker compose up -d - Supports weighted channel routing, failure retry, disabled-channel bypass, and model-level routing to reduce upstream impact on applications and Agents. - Supports Redis and in-memory cache for single-node and multi-node deployments. +### Performance and Scalability Governance + +| Capability | Description | +|------|------| +| Cache and multi-node scaling | Single-node deployments can use in-memory cache, while multi-node deployments can use Redis; user, token, channel affinity, and quota-related caches reduce repeated database reads, while `SESSION_SECRET`, `CRYPTO_SECRET`, and `NODE_NAME` keep sessions, encryption, and log attribution consistent | +| Rate limits and capacity protection | Supports global API / Web rate limits, critical endpoint limits, search limits, model request limits, and group-specific model request quotas; counters can use Redis or memory | +| Streaming and large-request controls | Supports `STREAMING_TIMEOUT`, `STREAM_SCANNER_MAX_BUFFER_MB`, `MAX_REQUEST_BODY_MB`, `MAX_FILE_DOWNLOAD_MB`, and related settings for long streams, large SSE lines, decompressed request bodies, and remote file downloads | +| Relay connection tuning | Supports `RELAY_TIMEOUT`, `RELAY_IDLE_CONN_TIMEOUT`, `RELAY_MAX_IDLE_CONNS`, and `RELAY_MAX_IDLE_CONNS_PER_HOST` for upstream HTTP timeout and connection-pool behavior | +| Disk cache and performance observability | System performance settings can enable disk cache for large request bodies and configure cache threshold and capacity; operations endpoints can inspect / clear disk cache, and Pyroscope can collect CPU, memory, goroutine, mutex, and block profiles | +| Graceful shutdown and data flush | Shutdown supports `SHUTDOWN_TIMEOUT_SECONDS` and `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS` so the process can close HTTP handling and save quota cache before exit when possible | + ### Security and Organization Management - Supports JWT, WebAuthn/Passkeys, OAuth, OIDC, Telegram, Discord, LinuxDO, and other login methods. @@ -213,6 +227,7 @@ docker compose up -d | Agent access | Agents directly hold upstream keys, making revocation and quota control difficult | Assign independent tokens to Agents with model, quota, expiration, and group limits | | Protocol differences | Applications adapt Claude, Gemini, Responses, and other formats themselves | Gateway handles protocol conversion and provider adaptation | | Failure handling | Applications implement retry, fallback, and error normalization themselves | Channel failure retry, weighted routing, and error handling are built in | +| Performance and scaling | Applications handle timeouts, rate limits, connection pools, and cache themselves | Gateway centralizes streaming timeouts, request limits, Redis / in-memory cache, connection-pool tuning, and performance observability | | Cost statistics | Bills are scattered across providers and hard to attribute by user or Agent | Unified quota, billing, usage statistics, and consumption logs, attributable by token and model | | Audit boundary | Application-side logging is fragmented and retention/permission policies differ | Unified admin audit entry with normal user logs filtering admin-only fields | | Private deployment | Keys, logs, and billing strategies are scattered | Self-hosted control over keys, data, logs, and policies | diff --git a/README.fr.md b/README.fr.md index d6b25b8d..fd77e9fa 100644 --- a/README.fr.md +++ b/README.fr.md @@ -44,6 +44,8 @@ MAX API est un projet d'infrastructure pour la gouvernance des modèles IA, l'AgentOps et les services applicatifs. Il est initié, maintenu et exploité sur le long terme par des passionnés d'AGI issus d'organismes de recherche et d'universités. Il fournit aux développeurs, chercheurs, équipes et organisations une couche de service stable et réutilisable. Le projet se concentre sur les problèmes d'exploitation qui apparaissent lorsque les applications IA passent en production : multiplication des modèles, évolution fréquente des API amont, chaînes d'appels Agent plus longues, pression accrue sur les coûts et l'audit. MAX API fournit une couche unifiée d'accès, d'authentification, de routage, de facturation, d'observabilité et de gouvernance entre applications, Agents, utilisateurs, organisations et fournisseurs de modèles. +En pratique, MAX API n'est pas seulement un proxy de requêtes. C'est une passerelle exploitable pour applications AI-ready et charges Agent, qui regroupe normalisation des protocoles, différences fournisseurs, pics de trafic, longs flux streaming, gros corps de requête, cache multi-nœud, audit des coûts et observabilité des performances dans une même frontière de gouvernance. + Axes d'investissement continus : - **Gouvernance des modèles IA** : suivi continu des mises à jour de modèles, des changements d'API, des différences de paramètres, des règles de prix et des protocoles de tâches chez OpenAI, Azure OpenAI, AWS Bedrock, Vertex AI, Ollama, ainsi que chez DeepSeek, Qwen / Alibaba Cloud Model Studio, Zhipu GLM, Kimi, Doubao / Volcano Engine, Tencent Hunyuan, Baidu ERNIE / Qianfan, iFlytek Spark, MiniMax, 01.AI, SiliconFlow et d'autres plateformes. Le projet suit aussi Dify, RAGFlow, Kling, Seedance et d'autres écosystèmes applicatifs ou multimodaux. @@ -75,6 +77,7 @@ Pour la production, privilégiez les versions stables. Utilisez les versions Pre - **Plan de configuration des canaux** : réduit les risques de mauvaise configuration grâce aux matrices de capacités, validations de formulaires, découverte de modèles et modèles de protocole. - **Couche d'adaptation protocoles/fournisseurs** : suit les API officielles internationales, les plateformes de modèles chinoises et les API OpenAI-compatible / non standard, puis les normalise en interfaces applicatives stables. - **Gouvernance coût, quota et fiabilité** : routage de canaux, distribution pondérée, retry, limitation, préfacturation, remboursement d'échec, facturation par expression, prix fixes, rate-cards de tâches, multiplicateurs et statistiques d'usage. +- **Gouvernance performance et scalabilité** : cache Redis / mémoire, limitation des requêtes modèles, timeouts streaming, tampons pour grandes réponses, limites de corps de requête, cache disque, profiling Pyroscope et arrêt gracieux pour les déploiements mono ou multi-nœuds. - **Couche d'exploitation et d'audit organisationnelle** : utilisateurs, groupes, déploiement privé, rétention des données, audit et optimisation continue. - **Modèles de gouvernance réutilisables** : capitalisation des templates de canaux, protocoles de tâches, configurations de prix, pratiques de déploiement et retours d'exploitation. @@ -187,6 +190,17 @@ docker compose up -d - Routage pondéré, retry, contournement des canaux désactivés et routage par modèle. - Cache Redis et mémoire pour déploiement mono ou multi-nœud. +### Gouvernance performance et scalabilité + +| Capacité | Description | +|------|------| +| Cache et extension multi-nœud | Déploiement mono-nœud avec cache mémoire, multi-nœud avec Redis ; les caches utilisateur, jeton, affinité de canal et quota réduisent les lectures répétées en base, tandis que `SESSION_SECRET`, `CRYPTO_SECRET` et `NODE_NAME` gardent sessions, chiffrement et attribution des journaux cohérents | +| Limitation et protection de capacité | Limites globales API / Web, limites d'endpoints critiques, recherche, requêtes modèles et quotas par groupe ; compteurs via Redis ou mémoire | +| Streaming et grandes requêtes | `STREAMING_TIMEOUT`, `STREAM_SCANNER_MAX_BUFFER_MB`, `MAX_REQUEST_BODY_MB`, `MAX_FILE_DOWNLOAD_MB` contrôlent longs flux, grandes lignes SSE, corps décompressés et téléchargements distants | +| Réglage des connexions relais | `RELAY_TIMEOUT`, `RELAY_IDLE_CONN_TIMEOUT`, `RELAY_MAX_IDLE_CONNS` et `RELAY_MAX_IDLE_CONNS_PER_HOST` configurent timeouts et pool HTTP amont | +| Cache disque et observabilité | Les paramètres de performance peuvent activer le cache disque pour grands corps, définir seuil et capacité ; les endpoints ops peuvent inspecter / nettoyer ce cache, et Pyroscope collecte CPU, mémoire, goroutines, mutex et block profiles | +| Arrêt gracieux et persistance | `SHUTDOWN_TIMEOUT_SECONDS` et `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS` aident à fermer HTTP et sauvegarder le cache quota avant sortie quand c'est possible | + ### Sécurité et gestion d'organisation - JWT, WebAuthn/Passkeys, OAuth, OIDC, Telegram, Discord, LinuxDO et autres méthodes de connexion. @@ -205,6 +219,7 @@ docker compose up -d | Accès Agent | Les Agents détiennent directement les clés amont | Jetons indépendants avec limites de modèles, quota, expiration et groupe | | Différences de protocole | L'application adapte Claude, Gemini, Responses, etc. | La passerelle convertit les protocoles et adapte les fournisseurs | | Échecs | Retry, fallback et normalisation d'erreurs côté application | Retry de canaux, routage pondéré et traitement d'erreurs intégrés | +| Performance et extension | L'application gère timeouts, limites, pools de connexions et cache | La passerelle centralise timeouts streaming, limites de requêtes, cache Redis / mémoire, réglage de pool et observabilité | | Coûts | Factures dispersées, attribution difficile | Quotas, facturation, statistiques et journaux unifiés par jeton et modèle | | Audit | Journaux applicatifs dispersés | Entrée d'audit admin unifiée, filtrage des champs admin pour utilisateurs | | Privé | Clés, journaux et stratégie tarifaire dispersés | Auto-hébergement et contrôle des clés, données, journaux et politiques | diff --git a/README.ja.md b/README.ja.md index 3313b9e3..8487179f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -44,6 +44,8 @@ MAX API は、研究機関と大学に所属する AGI 愛好者によって立ち上げられ、長期的に保守・運営されている AI モデルガバナンス、AgentOps、アプリケーションサービス基盤プロジェクトです。開発者、研究者、チーム、組織に対して、安定して再利用可能なサービスレイヤーを提供します。AI アプリケーションが実運用に入ると、モデル数の増加、上流 API の頻繁な変更、Agent の呼び出しチェーンの長大化、コストと監査の負担増加が発生します。MAX API は、アプリケーション、Agent、ユーザー、組織、上流モデルサービスの間に、アクセス、認証、ルーティング、課金、可観測性、ガバナンスの統一レイヤーを提供します。 +実運用では、MAX API は単なるリクエスト転送ではありません。AI-ready アプリケーションと Agent ワークロード向けの運用可能なゲートウェイとして、プロトコル正規化、プロバイダー差分、トラフィック集中、長時間ストリーミング、大きなリクエストボディ、複数ノードキャッシュ、コスト監査、性能観測を同じガバナンス境界にまとめます。 + 継続的に注力する領域: - **AI モデルガバナンス**:OpenAI、Azure OpenAI、AWS Bedrock、Vertex AI、Ollama、および DeepSeek、Qwen / Alibaba Cloud Model Studio、Zhipu GLM、Kimi、Doubao / Volcano Engine、Tencent Hunyuan、Baidu ERNIE / Qianfan、iFlytek Spark、MiniMax、01.AI、SiliconFlow などのモデル更新、API 変更、パラメータ差分、価格ルール、タスクプロトコルを継続的に追跡します。Dify、RAGFlow、Kling、Seedance などのアプリケーション・マルチモーダルエコシステムも対象です。 @@ -75,6 +77,7 @@ AGI アプリケーション時代において、MAX API はオープンな AI - **チャネル設定プレーン**:能力マトリクス、フォーム検証、モデル発見、プロトコルテンプレートによって、上流チャネル追加や非標準 API の保守における設定ミスを減らします。 - **プロトコル・プロバイダー適応レイヤー**:海外公式 API、中国国内モデルプラットフォーム API、OpenAI-compatible / 非標準 API の変化を追跡し、安定したアプリケーション側インターフェースへ正規化します。 - **コスト、クォータ、信頼性ガバナンス**:チャネルルーティング、重み付き分配、retry、レート制限、事前課金、失敗時返金、式ベース課金、固定価格、タスク rate-card、倍率課金、利用統計をサポートします。 +- **性能とスケーラビリティガバナンス**:Redis / メモリキャッシュ、モデルリクエスト制限、ストリーミング timeout、大きな応答バッファ、リクエストボディ上限、ディスクキャッシュ、Pyroscope profiling、graceful shutdown により、単一ノードから複数ノードまで安定運用を支えます。 - **組織運用・監査レイヤー**:チーム、研究機関、企業、コミュニティ向けに、ユーザー管理、グループ管理、プライベートデプロイ、データ保持、監査、継続運用最適化を提供します。 - **再利用可能なガバナンステンプレート**:チャネルテンプレート、タスクプロトコルテンプレート、価格設定、デプロイ実践、運用ノウハウを蓄積します。 @@ -187,6 +190,17 @@ docker compose up -d - 重み付きチャネルルーティング、失敗時 retry、無効チャネル回避、モデルレベルルーティングをサポートします。 - Redis キャッシュとメモリキャッシュにより、単一ノード・複数ノード構成に対応します。 +### 性能とスケーラビリティガバナンス + +| 機能 | 説明 | +|------|------| +| キャッシュと複数ノード拡張 | 単一ノードではメモリキャッシュ、複数ノードでは Redis を利用できます。ユーザー、トークン、チャネル affinity、クォータ関連キャッシュで DB 読み取りを減らし、`SESSION_SECRET`、`CRYPTO_SECRET`、`NODE_NAME` によりセッション、暗号化、ログ帰属を揃えます | +| レート制限と容量保護 | グローバル API / Web、重要 endpoint、検索、モデルリクエスト、グループ別モデルリクエスト quota の制限をサポートし、Redis またはメモリカウンターを利用できます | +| ストリーミングと大きなリクエスト制御 | `STREAMING_TIMEOUT`、`STREAM_SCANNER_MAX_BUFFER_MB`、`MAX_REQUEST_BODY_MB`、`MAX_FILE_DOWNLOAD_MB` で長時間 stream、大きな SSE 行、解凍後リクエストボディ、リモートファイル download を制御します | +| リレー接続調整 | `RELAY_TIMEOUT`、`RELAY_IDLE_CONN_TIMEOUT`、`RELAY_MAX_IDLE_CONNS`、`RELAY_MAX_IDLE_CONNS_PER_HOST` で上流 HTTP timeout と connection pool を調整できます | +| ディスクキャッシュと性能観測 | 性能設定で大きなリクエストボディ向けディスクキャッシュ、閾値、容量を設定できます。運用 endpoint で確認 / クリアでき、Pyroscope で CPU、メモリ、goroutine、mutex、block profile を収集できます | +| Graceful shutdown とデータ保存 | `SHUTDOWN_TIMEOUT_SECONDS` と `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS` により、可能な範囲で HTTP 処理を閉じ、クォータキャッシュを保存してから終了します | + ### セキュリティと組織管理 - JWT、WebAuthn/Passkeys、OAuth、OIDC、Telegram、Discord、LinuxDO などのログイン方式をサポートします。 @@ -205,6 +219,7 @@ docker compose up -d | Agent アクセス | Agent が上流 Key を直接保持し、回収や制限が難しい | Agent ごとに独立トークンを割り当て、モデル・クォータ・期限・グループを制限 | | プロトコル差分 | Claude、Gemini、Responses などをアプリ側で吸収 | ゲートウェイがプロトコル変換とプロバイダー適応を担当 | | 障害処理 | retry、fallback、エラー正規化をアプリ側で実装 | チャネル失敗時 retry、重み付きルーティング、エラー処理を内蔵 | +| 性能と拡張 | timeout、レート制限、接続 pool、cache をアプリ側で処理 | ゲートウェイが streaming timeout、リクエスト制限、Redis / メモリ cache、接続 pool 調整、性能観測を集約 | | コスト統計 | 請求が各平台に分散し、ユーザーや Agent への帰属が難しい | クォータ、課金、利用統計、消費ログを統一し、トークンとモデル単位で帰属可能 | | 監査境界 | アプリ側ログが分散し、権限・保持ポリシーが不統一 | 管理者側統一監査入口、通常ユーザーには管理者専用フィールドを除外 | | プライベート化 | Key、ログ、課金ポリシーが分散 | 自己ホストで Key、データ、ログ、ポリシーを掌握 | diff --git a/README.md b/README.md index c6612c6a..8f6bef8b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ MAX API 是由来自科研机构和高校的 AGI 爱好者组织发起、维护和长期运营的 AI 模型治理、AgentOps 与应用服务基础设施项目,面向开发者、研究者、团队和组织提供稳定、可复用的服务层能力。项目关注 AI 应用落地后的持续运营问题:模型越来越多、供应商接口频繁变化、Agent 调用链路变长、成本和审计压力增加。MAX API 在应用、Agent、用户、组织和上游模型服务之间提供统一的接入、鉴权、路由、计费、观测和治理层,让 AI 应用更稳定、更可控地运行。 +换句话说,MAX API 不只是请求转发器,而是面向 AI-ready 应用和 Agent 工作负载的可运营网关:在统一模型协议与供应商差异的同时,把突发流量、长流式响应、大请求体、多节点缓存、成本审计和性能观测放进同一套治理边界。 + 持续投入方向: - **AI 模型治理**:持续跟踪 OpenAI、Azure OpenAI、AWS Bedrock、Vertex AI、Ollama,以及 DeepSeek、通义千问 / 阿里云百炼、智谱 GLM、Kimi、豆包 / 火山引擎、腾讯混元、百度文心 / 千帆、讯飞星火、MiniMax、零一万物、硅基流动等模型与平台的模型更新、接口变化、参数差异、价格规则和任务协议;同时关注 Dify、RAGFlow、Kling、Seedance 等应用和多模态生态的接入形态,通过渠道、模型映射、协议转换、路径覆盖和可配置任务协议,把分散模型能力纳入统一治理。 @@ -85,6 +87,7 @@ MAX API 当前版本发布分为正式版和 Preview 预览版。Preview 预览 - **渠道配置平面**:通过能力矩阵、表单校验、模型发现和协议模板,降低新增上游渠道、迁移供应商和维护非标准接口时的误配置风险。 - **协议与供应商适配层**:持续跟踪海外官方接口、国产模型平台接口(DeepSeek、通义、智谱、火山引擎、百度千帆等)以及各类 OpenAI 兼容 / 非标准接口的变化,并规范化为稳定的应用侧接口。 - **成本、额度与可靠性治理**:支持渠道路由、加权分发、失败重试、限流、预扣费、失败退款,以及表达式计费、固定价格、任务 rate-card、倍率计费和用量统计。 +- **性能与可扩展性治理**:通过 Redis / 内存缓存、模型请求限流、流式超时与大响应缓冲、请求体上限、磁盘缓存、Pyroscope 性能采样和优雅关闭,支撑单机到多节点部署的稳定运行。 - **组织级运营与审计层**:为团队、研究机构、企业和社区服务提供用户管理、分组管理、私有化部署、数据留存、审计和持续运营优化能力。 - **可复用治理模板**:沉淀渠道模板、任务协议模板、价格配置、部署实践和运维经验,降低新模型、新供应商和新 Agent 场景的接入成本。 @@ -197,6 +200,17 @@ docker compose up -d - 支持渠道加权随机、失败重试、禁用渠道绕过和模型级路由,降低上游异常对应用和 Agent 的影响。 - 支持 Redis 缓存与内存缓存,适配单机和多机部署。 +### 性能与可扩展性治理 + +| 能力 | 说明 | +|------|------| +| 缓存与多节点扩展 | 单机可使用内存缓存,多机可接入 Redis;用户、令牌、渠道亲和、额度相关缓存减少重复数据库访问,并通过 `SESSION_SECRET`、`CRYPTO_SECRET`、`NODE_NAME` 保持会话、加密和日志归属一致 | +| 请求限流与容量保护 | 支持全局 API / Web 限流、关键接口限流、搜索限流、模型请求限流和按分组配置的模型请求配额;可使用 Redis 或内存计数器 | +| 流式与大请求控制 | 支持 `STREAMING_TIMEOUT`、`STREAM_SCANNER_MAX_BUFFER_MB`、`MAX_REQUEST_BODY_MB`、`MAX_FILE_DOWNLOAD_MB` 等配置,控制长流式响应、大行 SSE、解压后请求体和远程文件下载大小 | +| 中继连接调优 | 支持 `RELAY_TIMEOUT`、`RELAY_IDLE_CONN_TIMEOUT`、`RELAY_MAX_IDLE_CONNS`、`RELAY_MAX_IDLE_CONNS_PER_HOST` 配置上游 HTTP 连接池与超时策略 | +| 磁盘缓存与性能观测 | 系统性能设置可启用大请求体磁盘缓存、配置缓存阈值和容量;运维接口可查看 / 清理磁盘缓存,并可通过 Pyroscope 采集 CPU、内存、goroutine、mutex 和 block profile | +| 平滑退出与数据落盘 | 关闭进程时支持 `SHUTDOWN_TIMEOUT_SECONDS` 和 `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS`,尽量在退出前完成 HTTP 关闭和额度缓存保存 | + ### 安全与组织管理 - 支持 JWT、WebAuthn/Passkeys、OAuth、OIDC、Telegram、Discord、LinuxDO 等登录方式。 @@ -215,6 +229,7 @@ docker compose up -d | Agent 访问 | Agent 直接持有上游 Key,难以回收和限额 | 为 Agent 分配独立令牌,并限制模型、额度、过期时间和分组 | | 协议差异 | 应用自行适配 Claude、Gemini、Responses 等格式 | 网关统一做协议转换和供应商适配 | | 失败处理 | 应用自行实现重试、降级和错误归一 | 渠道失败自动重试、加权路由和错误处理 | +| 性能与扩展 | 应用自行处理超时、限流、连接池和缓存 | 网关集中提供流式超时、请求限制、Redis / 内存缓存、连接池调优和性能观测 | | 成本统计 | 各平台账单分散,难以按用户或 Agent 核算 | 统一额度、计费、用量统计和消费日志,可按令牌和模型归因 | | 审计边界 | 应用侧分散记录,权限和留存策略不统一 | 管理员侧统一审计入口,普通用户日志过滤管理员专用字段 | | 私有化 | 密钥、日志和计费策略分散 | 自托管,自主掌控密钥、数据、日志和策略 | diff --git a/README.zh_CN.md b/README.zh_CN.md index 63d3c6ee..65511d70 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -52,6 +52,8 @@ MAX API 是由来自科研机构和高校的 AGI 爱好者组织发起、维护和长期运营的 AI 模型治理、AgentOps 与应用服务基础设施项目,面向开发者、研究者、团队和组织提供稳定、可复用的服务层能力。项目关注 AI 应用落地后的持续运营问题:模型越来越多、供应商接口频繁变化、Agent 调用链路变长、成本和审计压力增加。MAX API 在应用、Agent、用户、组织和上游模型服务之间提供统一的接入、鉴权、路由、计费、观测和治理层,让 AI 应用更稳定、更可控地运行。 +换句话说,MAX API 不只是请求转发器,而是面向 AI-ready 应用和 Agent 工作负载的可运营网关:在统一模型协议与供应商差异的同时,把突发流量、长流式响应、大请求体、多节点缓存、成本审计和性能观测放进同一套治理边界。 + 持续投入方向: - **AI 模型治理**:持续跟踪 OpenAI、Azure OpenAI、AWS Bedrock、Vertex AI、Ollama,以及 DeepSeek、通义千问 / 阿里云百炼、智谱 GLM、Kimi、豆包 / 火山引擎、腾讯混元、百度文心 / 千帆、讯飞星火、MiniMax、零一万物、硅基流动等模型与平台的模型更新、接口变化、参数差异、价格规则和任务协议;同时关注 Dify、RAGFlow、Kling、Seedance 等应用和多模态生态的接入形态,通过渠道、模型映射、协议转换、路径覆盖和可配置任务协议,把分散模型能力纳入统一治理。 @@ -83,6 +85,7 @@ MAX API 当前版本发布分为正式版和 Preview 预览版。Preview 预览 - **渠道配置平面**:通过能力矩阵、表单校验、模型发现和协议模板,降低新增上游渠道、迁移供应商和维护非标准接口时的误配置风险。 - **协议与供应商适配层**:持续跟踪海外官方接口、国产模型平台接口(DeepSeek、通义、智谱、火山引擎、百度千帆等)以及各类 OpenAI 兼容 / 非标准接口的变化,并规范化为稳定的应用侧接口。 - **成本、额度与可靠性治理**:支持渠道路由、加权分发、失败重试、限流、预扣费、失败退款,以及表达式计费、固定价格、任务 rate-card、倍率计费和用量统计。 +- **性能与可扩展性治理**:通过 Redis / 内存缓存、模型请求限流、流式超时与大响应缓冲、请求体上限、磁盘缓存、Pyroscope 性能采样和优雅关闭,支撑单机到多节点部署的稳定运行。 - **组织级运营与审计层**:为团队、研究机构、企业和社区服务提供用户管理、分组管理、私有化部署、数据留存、审计和持续运营优化能力。 - **可复用治理模板**:沉淀渠道模板、任务协议模板、价格配置、部署实践和运维经验,降低新模型、新供应商和新 Agent 场景的接入成本。 @@ -195,6 +198,17 @@ docker compose up -d - 支持渠道加权随机、失败重试、禁用渠道绕过和模型级路由,降低上游异常对应用和 Agent 的影响。 - 支持 Redis 缓存与内存缓存,适配单机和多机部署。 +### 性能与可扩展性治理 + +| 能力 | 说明 | +|------|------| +| 缓存与多节点扩展 | 单机可使用内存缓存,多机可接入 Redis;用户、令牌、渠道亲和、额度相关缓存减少重复数据库访问,并通过 `SESSION_SECRET`、`CRYPTO_SECRET`、`NODE_NAME` 保持会话、加密和日志归属一致 | +| 请求限流与容量保护 | 支持全局 API / Web 限流、关键接口限流、搜索限流、模型请求限流和按分组配置的模型请求配额;可使用 Redis 或内存计数器 | +| 流式与大请求控制 | 支持 `STREAMING_TIMEOUT`、`STREAM_SCANNER_MAX_BUFFER_MB`、`MAX_REQUEST_BODY_MB`、`MAX_FILE_DOWNLOAD_MB` 等配置,控制长流式响应、大行 SSE、解压后请求体和远程文件下载大小 | +| 中继连接调优 | 支持 `RELAY_TIMEOUT`、`RELAY_IDLE_CONN_TIMEOUT`、`RELAY_MAX_IDLE_CONNS`、`RELAY_MAX_IDLE_CONNS_PER_HOST` 配置上游 HTTP 连接池与超时策略 | +| 磁盘缓存与性能观测 | 系统性能设置可启用大请求体磁盘缓存、配置缓存阈值和容量;运维接口可查看 / 清理磁盘缓存,并可通过 Pyroscope 采集 CPU、内存、goroutine、mutex 和 block profile | +| 平滑退出与数据落盘 | 关闭进程时支持 `SHUTDOWN_TIMEOUT_SECONDS` 和 `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS`,尽量在退出前完成 HTTP 关闭和额度缓存保存 | + ### 安全与组织管理 - 支持 JWT、WebAuthn/Passkeys、OAuth、OIDC、Telegram、Discord、LinuxDO 等登录方式。 @@ -213,6 +227,7 @@ docker compose up -d | Agent 访问 | Agent 直接持有上游 Key,难以回收和限额 | 为 Agent 分配独立令牌,并限制模型、额度、过期时间和分组 | | 协议差异 | 应用自行适配 Claude、Gemini、Responses 等格式 | 网关统一做协议转换和供应商适配 | | 失败处理 | 应用自行实现重试、降级和错误归一 | 渠道失败自动重试、加权路由和错误处理 | +| 性能与扩展 | 应用自行处理超时、限流、连接池和缓存 | 网关集中提供流式超时、请求限制、Redis / 内存缓存、连接池调优和性能观测 | | 成本统计 | 各平台账单分散,难以按用户或 Agent 核算 | 统一额度、计费、用量统计和消费日志,可按令牌和模型归因 | | 审计边界 | 应用侧分散记录,权限和留存策略不统一 | 管理员侧统一审计入口,普通用户日志过滤管理员专用字段 | | 私有化 | 密钥、日志和计费策略分散 | 自托管,自主掌控密钥、数据、日志和策略 | diff --git a/README.zh_TW.md b/README.zh_TW.md index c705ed47..2f586654 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -52,6 +52,8 @@ MAX API 是由來自科研機構和高校的 AGI 愛好者組織發起、維護和長期營運的 AI 模型治理、AgentOps 與應用服務基礎設施專案,面向開發者、研究者、團隊和組織提供穩定、可復用的服務層能力。專案關注 AI 應用落地后的持續營運問題:模型越來越多、供應商介面頻繁變化、Agent 調用鏈路變長、成本和審計壓力增加。MAX API 在應用、Agent、使用者、組織和上游模型服務之間提供統一的接入、鑒權、路由、計費、觀測和治理層,讓 AI 應用更穩定、更可控地運行。 +換句話說,MAX API 不只是請求轉發器,而是面向 AI-ready 應用和 Agent 工作負載的可營運網關:在統一模型協議與供應商差異的同時,把突發流量、長流式響應、大請求體、多節點緩存、成本審計和性能觀測放進同一套治理邊界。 + 持續投入方向: - **AI 模型治理**:持續跟蹤 OpenAI、Azure OpenAI、AWS Bedrock、Vertex AI、Ollama,以及 DeepSeek、通義千問 / 阿里云百煉、智譜 GLM、Kimi、豆包 / 火山引擎、騰訊混元、百度文心 / 千帆、訊飛星火、MiniMax、零一萬物、硅基流動等模型與平臺的模型更新、介面變化、參數差異、價格規則和任務協議;同時關注 Dify、RAGFlow、Kling、Seedance 等應用和多模態生態的接入形態,通過渠道、模型映射、協議轉換、路徑覆蓋和可配置任務協議,把分散模型能力納入統一治理。 @@ -83,6 +85,7 @@ MAX API 當前版本發布分為正式版和 Preview 預覽版。Preview 預覽 - **渠道配置平面**:通過能力矩陣、表單校驗、模型發現和協議模板,降低新增上游渠道、遷移供應商和維護非標準介面時的誤配置風險。 - **協議與供應商適配層**:持續跟蹤海外官方介面、國產模型平臺介面(DeepSeek、通義、智譜、火山引擎、百度千帆等)以及各類 OpenAI 兼容 / 非標準介面的變化,并規范化為穩定的應用側介面。 - **成本、額度與可靠性治理**:支持渠道路由、加權分發、失敗重試、限流、預扣費、失敗退款,以及表達式計費、固定價格、任務 rate-card、倍率計費和用量統計。 +- **性能與可擴展性治理**:通過 Redis / 內存緩存、模型請求限流、流式超時與大響應緩衝、請求體上限、磁盤緩存、Pyroscope 性能采樣和優雅關閉,支撐單機到多節點部署的穩定運行。 - **組織級營運與審計層**:為團隊、研究機構、企業和社區服務提供使用者管理、分組管理、私有化部署、資料留存、審計和持續營運優化能力。 - **可復用治理模板**:沉淀渠道模板、任務協議模板、價格配置、部署實踐和運維經驗,降低新模型、新供應商和新 Agent 場景的接入成本。 @@ -195,6 +198,17 @@ docker compose up -d - 支持渠道加權隨機、失敗重試、禁用渠道繞過和模型級路由,降低上游異常對應用和 Agent 的影響。 - 支持 Redis 緩存與內存緩存,適配單機和多機部署。 +### 性能與可擴展性治理 + +| 能力 | 說明 | +|------|------| +| 緩存與多節點擴展 | 單機可使用內存緩存,多機可接入 Redis;使用者、令牌、渠道親和、額度相關緩存減少重復資料庫訪問,并通過 `SESSION_SECRET`、`CRYPTO_SECRET`、`NODE_NAME` 保持會話、加密和日志歸屬一致 | +| 請求限流與容量保護 | 支持全局 API / Web 限流、關鍵介面限流、搜索限流、模型請求限流和按分組配置的模型請求配額;可使用 Redis 或內存計數器 | +| 流式與大請求控制 | 支持 `STREAMING_TIMEOUT`、`STREAM_SCANNER_MAX_BUFFER_MB`、`MAX_REQUEST_BODY_MB`、`MAX_FILE_DOWNLOAD_MB` 等配置,控制長流式響應、大行 SSE、解壓后請求體和遠程文件下載大小 | +| 中繼連接調優 | 支持 `RELAY_TIMEOUT`、`RELAY_IDLE_CONN_TIMEOUT`、`RELAY_MAX_IDLE_CONNS`、`RELAY_MAX_IDLE_CONNS_PER_HOST` 配置上游 HTTP 連接池與超時策略 | +| 磁盤緩存與性能觀測 | 系統性能設置可啟用大請求體磁盤緩存、配置緩存閾值和容量;運維介面可查看 / 清理磁盤緩存,并可通過 Pyroscope 采集 CPU、內存、goroutine、mutex 和 block profile | +| 平滑退出與資料落盤 | 關閉進程時支持 `SHUTDOWN_TIMEOUT_SECONDS` 和 `QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS`,盡量在退出前完成 HTTP 關閉和額度緩存保存 | + ### 安全與組織管理 - 支持 JWT、WebAuthn/Passkeys、OAuth、OIDC、Telegram、Discord、LinuxDO 等登錄方式。 @@ -213,6 +227,7 @@ docker compose up -d | Agent 訪問 | Agent 直接持有上游 Key,難以回收和限額 | 為 Agent 分配獨立令牌,并限制模型、額度、過期時間和分組 | | 協議差異 | 應用自行適配 Claude、Gemini、Responses 等格式 | 網關統一做協議轉換和供應商適配 | | 失敗處理 | 應用自行實現重試、降級和錯誤歸一 | 渠道失敗自動重試、加權路由和錯誤處理 | +| 性能與擴展 | 應用自行處理超時、限流、連接池和緩存 | 網關集中提供流式超時、請求限制、Redis / 內存緩存、連接池調優和性能觀測 | | 成本統計 | 各平臺賬單分散,難以按使用者或 Agent 核算 | 統一額度、計費、用量統計和消費日志,可按令牌和模型歸因 | | 審計邊界 | 應用側分散記錄,權限和留存策略不統一 | 管理員側統一審計入口,普通使用者日志過濾管理員專用字段 | | 私有化 | 密鑰、日志和計費策略分散 | 自托管,自主掌控密鑰、資料、日志和策略 | diff --git a/controller/user.go b/controller/user.go index 2388f1a1..22e99b99 100644 --- a/controller/user.go +++ b/controller/user.go @@ -609,6 +609,25 @@ func GetUserModels(c *gin.Context) { return } groups := service.GetUserUsableGroups(user.Group) + group := c.Query("group") + if group != "" { + if _, ok := groups[group]; !ok { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": []string{}, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": model.GetGroupEnabledModels(group), + }) + return + } + var models []string for group := range groups { for _, g := range model.GetGroupEnabledModels(group) { diff --git a/web/default/src/components/model-group-selector.tsx b/web/default/src/components/model-group-selector.tsx index 4c1e463f..c74963d8 100644 --- a/web/default/src/components/model-group-selector.tsx +++ b/web/default/src/components/model-group-selector.tsx @@ -576,20 +576,199 @@ export const ModelGroupSelector: React.FC = ({ className, disabled = false, }) => { - return ( -
- - models.find((model) => model.value === selectedModel), + [models, selectedModel] + ) + const currentGroup = useMemo( + () => groups.find((group) => group.value === selectedGroup), + [groups, selectedGroup] + ) + const filteredModels = useMemo(() => { + const query = searchQuery.trim().toLowerCase() + if (!query) { + return models + } + + return models.filter((model) => { + const searchableText = [ + model.label, + model.value, + model.description || '', + model.category || '', + ] + .join(' ') + .toLowerCase() + + return searchableText.includes(query) + }) + }, [models, searchQuery]) + + const handleModelChange = useCallback( + (value: string) => { + onModelChange(value) + setOpen(false) + setSearchQuery('') + }, + [onModelChange] + ) + + const handleGroupChange = useCallback( + (value: string) => { + onGroupChange(value) + setSearchQuery('') + }, + [onGroupChange] + ) + + const renderTrigger = () => ( + + ) + + const renderGroupList = () => ( +
+
+ {t('Model Group')} +
+
+ {groups.map((group) => { + const isSelected = selectedGroup === group.value + + return ( + + ) + })} +
+
+ ) + + const renderModelList = () => ( + 1} + shouldFilter={false} + > + + + {filteredModels.length === 0 ? ( +
+ {t('No model found.')} +
+ ) : ( + + {filteredModels.map((model) => ( + + + {model.label} + + + + ))} + + )} +
+
+ ) + + const renderContent = () => ( +
+ {renderGroupList()} +
+ {renderModelList()} +
) + + return ( + <> + {isMobile ? ( + + {renderTrigger()} + + + {t('Select Model')} + +
+ {renderContent()} +
+
+
+ ) : ( + + + + {renderContent()} + + + )} + + ) } diff --git a/web/default/src/features/playground/api.ts b/web/default/src/features/playground/api.ts index 18efc07e..5c71b54f 100644 --- a/web/default/src/features/playground/api.ts +++ b/web/default/src/features/playground/api.ts @@ -40,8 +40,10 @@ export async function sendChatCompletion( /** * Get user available models */ -export async function getUserModels(): Promise { - const res = await api.get(API_ENDPOINTS.USER_MODELS) +export async function getUserModels(group: string): Promise { + const res = await api.get(API_ENDPOINTS.USER_MODELS, { + params: { group }, + }) const { data } = res if (!data.success || !Array.isArray(data.data)) { diff --git a/web/default/src/features/playground/components/playground-input.tsx b/web/default/src/features/playground/components/playground-input.tsx index 656a74cc..a7f69d2a 100644 --- a/web/default/src/features/playground/components/playground-input.tsx +++ b/web/default/src/features/playground/components/playground-input.tsx @@ -41,6 +41,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { PromptInput, PromptInputButton, @@ -49,12 +55,6 @@ import { PromptInputTools, type PromptInputMessage, } from '@/components/ai-elements/prompt-input' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion' import { ModelGroupSelector } from '@/components/model-group-selector' import type { ModelOption, GroupOption } from '../types' @@ -104,9 +104,7 @@ export function PlaygroundInput({ const { t } = useTranslation() const [text, setText] = useState('') - const isModelSelectDisabled = - disabled || isModelLoading || models.length === 0 - const isGroupSelectDisabled = disabled || groups.length === 0 + const isSelectorDisabled = disabled || isModelLoading || groups.length === 0 const isClearHistoryDisabled = disabled || isGenerating || !hasMessages const handleSubmit = (message: PromptInputMessage) => { @@ -231,7 +229,7 @@ export function PlaygroundInput({ selectedGroup={groupValue} groups={groups} onGroupChange={onGroupChange} - disabled={isModelSelectDisabled || isGroupSelectDisabled} + disabled={isSelectorDisabled} /> {isGenerating && onStop ? ( diff --git a/web/default/src/features/playground/hooks/index.ts b/web/default/src/features/playground/hooks/index.ts index 4193c34a..0f084ee0 100644 --- a/web/default/src/features/playground/hooks/index.ts +++ b/web/default/src/features/playground/hooks/index.ts @@ -20,3 +20,4 @@ export * from './use-playground-state' export * from './use-stream-request' export * from './use-chat-handler' export * from './use-message-action-guard' +export * from './use-playground-options' diff --git a/web/default/src/features/playground/hooks/use-playground-options.ts b/web/default/src/features/playground/hooks/use-playground-options.ts new file mode 100644 index 00000000..93092bf3 --- /dev/null +++ b/web/default/src/features/playground/hooks/use-playground-options.ts @@ -0,0 +1,129 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { getUserGroups, getUserModels } from '../api' +import { + getGroupFallback, + getModelFallback, + getOptionLoadErrorMessage, + shouldClearModelForGroup, +} from '../lib' +import type { GroupOption, ModelOption, PlaygroundConfig } from '../types' + +type UsePlaygroundOptionsParams = { + currentGroup: string + currentModel: string + setGroups: (groups: GroupOption[]) => void + setModels: (models: ModelOption[]) => void + updateConfig: ( + key: K, + value: PlaygroundConfig[K] + ) => void +} + +export function usePlaygroundOptions({ + currentGroup, + currentModel, + setGroups, + setModels, + updateConfig, +}: UsePlaygroundOptionsParams) { + const { t } = useTranslation() + + const { + data: groupsData, + error: groupsError, + isError: isGroupsError, + } = useQuery({ + queryKey: ['playground-groups'], + queryFn: getUserGroups, + }) + + const { + data: modelsData, + error: modelsError, + isError: isModelsError, + isLoading: isLoadingModels, + } = useQuery({ + queryKey: ['playground-models', currentGroup], + queryFn: () => getUserModels(currentGroup), + enabled: currentGroup !== '', + }) + + useEffect(() => { + if (!isGroupsError) return + + toast.error( + getOptionLoadErrorMessage( + groupsError, + t('Failed to load playground groups') + ) + ) + }, [groupsError, isGroupsError, t]) + + useEffect(() => { + if (!isModelsError) return + + toast.error( + getOptionLoadErrorMessage( + modelsError, + t('Failed to load playground models') + ) + ) + }, [isModelsError, modelsError, t]) + + useEffect(() => { + setModels([]) + }, [currentGroup, setModels]) + + useEffect(() => { + if (!groupsData) return + + setGroups(groupsData) + const fallback = getGroupFallback(groupsData, currentGroup) + + if (fallback) { + updateConfig('group', fallback) + } + }, [currentGroup, groupsData, setGroups, updateConfig]) + + useEffect(() => { + if (!modelsData) return + + setModels(modelsData) + const fallback = getModelFallback(modelsData, currentModel) + + if (fallback) { + updateConfig('model', fallback) + return + } + + if (shouldClearModelForGroup(modelsData, currentModel)) { + updateConfig('model', '') + } + }, [currentModel, modelsData, setModels, updateConfig]) + + return { + isLoadingModels, + currentModels: currentGroup === '' ? undefined : modelsData, + } +} diff --git a/web/default/src/features/playground/index.tsx b/web/default/src/features/playground/index.tsx index 325c33a6..1cd2ba54 100644 --- a/web/default/src/features/playground/index.tsx +++ b/web/default/src/features/playground/index.tsx @@ -16,21 +16,18 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { handleServerError } from '@/lib/handle-server-error' -import { getUserModels, getUserGroups } from './api' import { PlaygroundChat } from './components/playground-chat' import { PlaygroundInput } from './components/playground-input' -import { usePlaygroundState, useChatHandler } from './hooks' -import { createUserMessage, createLoadingAssistantMessage } from './lib' -import { queryDataOrEmptyOnError } from './lib/query-state' import { - getAvailableOptionValue, - isOptionValueAvailable, -} from './lib/selection-state' + useChatHandler, + usePlaygroundOptions, + usePlaygroundState, +} from './hooks' +import { createUserMessage, createLoadingAssistantMessage } from './lib' +import { isOptionValueAvailable } from './lib/selection-state' import type { Message as MessageType } from './types' export function Playground() { @@ -59,78 +56,21 @@ export function Playground() { null ) - // Load models - const { - data: modelsData, - error: modelsError, - isLoading: isLoadingModels, - } = useQuery({ - queryKey: ['playground-models'], - queryFn: getUserModels, + const { isLoadingModels, currentModels } = usePlaygroundOptions({ + currentGroup: config.group, + currentModel: config.model, + setGroups, + setModels, + updateConfig, }) - // Load groups - const { data: groupsData, error: groupsError } = useQuery({ - queryKey: ['playground-groups'], - queryFn: getUserGroups, - }) - const effectiveModelsData = useMemo( - () => queryDataOrEmptyOnError(modelsData, modelsError), - [modelsData, modelsError] - ) - const effectiveGroupsData = useMemo( - () => queryDataOrEmptyOnError(groupsData, groupsError), - [groupsData, groupsError] - ) const canSendChat = useMemo(() => { return ( - isOptionValueAvailable(effectiveModelsData, config.model) && - isOptionValueAvailable(effectiveGroupsData, config.group) + !isLoadingModels && + isOptionValueAvailable(currentModels, config.model) && + isOptionValueAvailable(groups, config.group) ) - }, [effectiveModelsData, effectiveGroupsData, config.model, config.group]) - - useEffect(() => { - if (!modelsError) return - - handleServerError(modelsError) - }, [modelsError]) - - useEffect(() => { - if (!groupsError) return - - handleServerError(groupsError) - }, [groupsError]) - - // Update models when data changes - useEffect(() => { - if (!effectiveModelsData) return - - setModels(effectiveModelsData) - - const nextModel = getAvailableOptionValue( - effectiveModelsData, - config.model - ) - if (nextModel !== config.model) { - updateConfig('model', nextModel) - } - }, [effectiveModelsData, config.model, setModels, updateConfig]) - - // Update groups when data changes - useEffect(() => { - if (!effectiveGroupsData) return - - setGroups(effectiveGroupsData) - - const nextGroup = getAvailableOptionValue( - effectiveGroupsData, - config.group, - 'default' - ) - if (nextGroup !== config.group) { - updateConfig('group', nextGroup) - } - }, [effectiveGroupsData, setGroups, config.group, updateConfig]) + }, [config.group, config.model, currentModels, groups, isLoadingModels]) const handleSendMessage = (text: string) => { if (!canSendChat) return diff --git a/web/default/src/features/playground/lib/index.ts b/web/default/src/features/playground/lib/index.ts index 7cc3888d..085ba088 100644 --- a/web/default/src/features/playground/lib/index.ts +++ b/web/default/src/features/playground/lib/index.ts @@ -20,3 +20,4 @@ export * from './message-utils' export * from './payload-builder' export * from './storage' export * from './message-styles' +export * from './playground-option-utils' diff --git a/web/default/src/features/playground/lib/playground-option-utils.test.ts b/web/default/src/features/playground/lib/playground-option-utils.test.ts new file mode 100644 index 00000000..ffde4634 --- /dev/null +++ b/web/default/src/features/playground/lib/playground-option-utils.test.ts @@ -0,0 +1,69 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + getGroupFallback, + getModelFallback, + shouldClearModelForGroup, +} from './playground-option-utils' + +describe('playground-option-utils', () => { + test('keeps the current model when it is available in the selected group', () => { + assert.equal( + getModelFallback([{ label: 'gpt-4o', value: 'gpt-4o' }], 'gpt-4o'), + null + ) + }) + + test('falls back to the first model when the current model is not in the selected group', () => { + assert.equal( + getModelFallback( + [{ label: 'gpt-4o-mini', value: 'gpt-4o-mini' }], + 'gpt-4o' + ), + 'gpt-4o-mini' + ) + }) + + test('does not choose a model fallback from an empty model list', () => { + assert.equal(getModelFallback([], 'gpt-4o'), null) + assert.equal(shouldClearModelForGroup([], 'gpt-4o'), true) + }) + + test('prefers the default group when the current group is unavailable', () => { + assert.equal( + getGroupFallback( + [ + { label: 'vip', value: 'vip', ratio: 2 }, + { label: 'default', value: 'default', ratio: 1 }, + ], + 'stale' + ), + 'default' + ) + }) + + test('falls back to the first group when default is unavailable', () => { + assert.equal( + getGroupFallback([{ label: 'vip', value: 'vip', ratio: 2 }], 'stale'), + 'vip' + ) + }) +}) diff --git a/web/default/src/features/playground/lib/playground-option-utils.ts b/web/default/src/features/playground/lib/playground-option-utils.ts new file mode 100644 index 00000000..0846aa87 --- /dev/null +++ b/web/default/src/features/playground/lib/playground-option-utils.ts @@ -0,0 +1,65 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { GroupOption, ModelOption } from '../types' + +export function getModelFallback( + models: ModelOption[], + currentModel: string +): string | null { + const hasCurrentModel = models.some((model) => model.value === currentModel) + + if (hasCurrentModel || models.length === 0) { + return null + } + + return models[0].value +} + +export function shouldClearModelForGroup( + models: ModelOption[], + currentModel: string +): boolean { + if (currentModel === '') { + return false + } + + return !models.some((model) => model.value === currentModel) +} + +export function getGroupFallback( + groups: GroupOption[], + currentGroup: string +): string | null { + const hasCurrentGroup = groups.some((group) => group.value === currentGroup) + + if (hasCurrentGroup || groups.length === 0) { + return null + } + + return ( + groups.find((group) => group.value === 'default')?.value ?? groups[0].value + ) +} + +export function getOptionLoadErrorMessage( + error: unknown, + fallbackMessage: string +): string { + return error instanceof Error ? error.message : fallbackMessage +} diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index 1e92237b..f1490503 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -32,6 +32,7 @@ import { } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { handleServerError } from '@/lib/handle-server-error' import { SettingsControlChildren, SettingsForm, @@ -137,29 +138,35 @@ export function HeaderNavigationSection({ useResetForm(form, formDefaults) const onSubmit = async (values: HeaderNavFormValues) => { - await headerNavOption.updateHeaderNavModules((current) => ({ - ...current, - home: values.home, - console: values.console, - docs: values.docs, - about: values.about, - custom: { - ...(current.custom ?? HEADER_NAV_DEFAULT.custom), - enabled: values.customEnabled, - title: values.customTitle.trim(), - href: values.customHref.trim(), - }, - pricing: { - ...(current.pricing ?? HEADER_NAV_DEFAULT.pricing), - enabled: values.pricingEnabled, - requireAuth: values.pricingRequireAuth, - }, - })) - await rankingsOption.updateRankingsModule((current) => ({ - ...current, - enabled: values.rankingsEnabled, - requireAuth: values.rankingsRequireAuth, - })) + try { + await Promise.all([ + headerNavOption.updateHeaderNavModules((current) => ({ + ...current, + home: values.home, + console: values.console, + docs: values.docs, + about: values.about, + custom: { + ...(current.custom ?? HEADER_NAV_DEFAULT.custom), + enabled: values.customEnabled, + title: values.customTitle.trim(), + href: values.customHref.trim(), + }, + pricing: { + ...(current.pricing ?? HEADER_NAV_DEFAULT.pricing), + enabled: values.pricingEnabled, + requireAuth: values.pricingRequireAuth, + }, + })), + rankingsOption.updateRankingsModule((current) => ({ + ...current, + enabled: values.rankingsEnabled, + requireAuth: values.rankingsRequireAuth, + })), + ]) + } catch (error) { + handleServerError(error) + } } const resetToDefault = () => { From 03488067c85e0dccaff3b0a591fd3dfd7f5ab9c6 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 12:00:58 +0800 Subject: [PATCH 03/10] v1.0.4 --- .../lib/playground-option-utils.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/web/default/src/features/playground/lib/playground-option-utils.test.ts b/web/default/src/features/playground/lib/playground-option-utils.test.ts index ffde4634..d33d2b12 100644 --- a/web/default/src/features/playground/lib/playground-option-utils.test.ts +++ b/web/default/src/features/playground/lib/playground-option-utils.test.ts @@ -21,6 +21,7 @@ import { describe, test } from 'node:test' import { getGroupFallback, getModelFallback, + getOptionLoadErrorMessage, shouldClearModelForGroup, } from './playground-option-utils' @@ -47,6 +48,10 @@ describe('playground-option-utils', () => { assert.equal(shouldClearModelForGroup([], 'gpt-4o'), true) }) + test('does not clear an already-empty model selection', () => { + assert.equal(shouldClearModelForGroup([], ''), false) + }) + test('prefers the default group when the current group is unavailable', () => { assert.equal( getGroupFallback( @@ -66,4 +71,15 @@ describe('playground-option-utils', () => { 'vip' ) }) + + test('returns the error message for Error instances', () => { + assert.equal( + getOptionLoadErrorMessage(new Error('boom'), 'fallback'), + 'boom' + ) + }) + + test('returns the fallback message for non-Error values', () => { + assert.equal(getOptionLoadErrorMessage('boom', 'fallback'), 'fallback') + }) }) From 2475c83a40468be62db993a7c8f83ea4437e4925 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 16:39:31 +0800 Subject: [PATCH 04/10] v1.0.4 --- controller/oauth.go | 26 +++++- controller/oauth_test.go | 33 +++++++ controller/token.go | 2 +- controller/token_test.go | 93 ++++++++++++++++--- model/log.go | 45 +++++---- model/log_test.go | 136 ++++++++++++++++++++++++++-- relay/helper/stream_scanner.go | 35 ++++++- relay/helper/stream_scanner_test.go | 84 +++++++++++++++-- 8 files changed, 401 insertions(+), 53 deletions(-) create mode 100644 controller/oauth_test.go diff --git a/controller/oauth.go b/controller/oauth.go index 7db43379..096c6dbe 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -20,6 +20,21 @@ func providerParams(name string) map[string]any { return map[string]any{"Provider": name} } +func oauthProviderUserUpdateField(provider oauth.Provider) (model.UserUpdateField, bool) { + switch provider.(type) { + case *oauth.GitHubProvider: + return model.UserUpdateFieldGitHubId, true + case *oauth.DiscordProvider: + return model.UserUpdateFieldDiscordId, true + case *oauth.OIDCProvider: + return model.UserUpdateFieldOidcId, true + case *oauth.LinuxDOProvider: + return model.UserUpdateFieldLinuxDOId, true + default: + return "", false + } +} + // GenerateOAuthCode generates a state code for OAuth CSRF protection func GenerateOAuthCode(c *gin.Context) { session := sessions.Default(c) @@ -107,10 +122,6 @@ func HandleOAuth(c *gin.Context) { // 7. Find or create user user, err := findOrCreateOAuthUser(c, provider, oauthUser, session) if err != nil { - if errors.Is(err, model.ErrEmailAlreadyTaken) { - common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) - return - } switch err.(type) { case *OAuthUserDeletedError: common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted) @@ -190,7 +201,12 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) { } else { // Built-in provider: update user record directly provider.SetProviderUserID(&user, oauthUser.ProviderUserID) - err = user.Update(false) + updateField, ok := oauthProviderUserUpdateField(provider) + if !ok { + common.ApiError(c, fmt.Errorf("unsupported built-in OAuth provider: %s", provider.GetName())) + return + } + err = user.UpdateFields(false, updateField) if err != nil { common.ApiError(c, err) return diff --git a/controller/oauth_test.go b/controller/oauth_test.go new file mode 100644 index 00000000..fd79911c --- /dev/null +++ b/controller/oauth_test.go @@ -0,0 +1,33 @@ +package controller + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/oauth" +) + +func TestOAuthProviderUserUpdateField(t *testing.T) { + tests := []struct { + name string + provider oauth.Provider + want model.UserUpdateField + }{ + {name: "github", provider: &oauth.GitHubProvider{}, want: model.UserUpdateFieldGitHubId}, + {name: "discord", provider: &oauth.DiscordProvider{}, want: model.UserUpdateFieldDiscordId}, + {name: "oidc", provider: &oauth.OIDCProvider{}, want: model.UserUpdateFieldOidcId}, + {name: "linuxdo", provider: &oauth.LinuxDOProvider{}, want: model.UserUpdateFieldLinuxDOId}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := oauthProviderUserUpdateField(tt.provider) + if !ok { + t.Fatalf("expected provider field mapping") + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} diff --git a/controller/token.go b/controller/token.go index ae74304f..19859be0 100644 --- a/controller/token.go +++ b/controller/token.go @@ -41,7 +41,7 @@ func validateAssignableTokenGroup(c *gin.Context, group string) bool { if userGroup == "" { userGroup = c.GetString("user_group") } - if service.CanUseTokenGroup(userGroup, group) { + if service.CanUseTokenGroupRuntime(userGroup, group) { return true } common.ApiErrorI18n(c, i18n.MsgTokenGroupNotAssignable, map[string]any{"Group": group}) diff --git a/controller/token_test.go b/controller/token_test.go index a78c8f3e..c4314acd 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -522,7 +522,7 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) { } } -func TestAddTokenRejectsNonSelectableAutoRoute(t *testing.T) { +func TestAddTokenAllowsRuntimeAutoRoute(t *testing.T) { db := setupTokenControllerTestDB(t) setupHiddenAutoRouteForTokenTest(t) if err := appi18n.Init(); err != nil { @@ -545,12 +545,48 @@ func TestAddTokenRejectsNonSelectableAutoRoute(t *testing.T) { ctx.Request.Header.Set("Accept-Language", "zh-CN") AddToken(ctx) + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected hidden runtime route token creation to succeed, got message: %s", response.Message) + } + + var stored model.Token + if err := db.First(&stored).Error; err != nil { + t.Fatalf("failed to load created token: %v", err) + } + if stored.Group != "auto:internal" { + t.Fatalf("expected token group auto:internal, got %q", stored.Group) + } +} + +func TestAddTokenRejectsUnavailableGroup(t *testing.T) { + db := setupTokenControllerTestDB(t) + if err := appi18n.Init(); err != nil { + t.Fatalf("failed to initialize i18n: %v", err) + } + + body := map[string]any{ + "name": "invalid-group-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "group": "missing", + "cross_group_retry": true, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + ctx.Request.Header.Set("Accept-Language", "zh-CN") + AddToken(ctx) + response := decodeAPIResponse(t, recorder) if response.Success { - t.Fatalf("expected hidden auto route token creation to fail") + t.Fatalf("expected unavailable group token creation to fail") } - if !strings.Contains(response.Message, "auto:internal") { - t.Fatalf("expected error message to mention hidden route, got %q", response.Message) + if !strings.Contains(response.Message, "missing") { + t.Fatalf("expected error message to mention unavailable group, got %q", response.Message) } if !strings.Contains(response.Message, "无权分配") { t.Fatalf("expected localized denial message, got %q", response.Message) @@ -601,7 +637,7 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) { } } -func TestUpdateTokenRejectsNonSelectableAutoRoute(t *testing.T) { +func TestUpdateTokenAllowsRuntimeAutoRoute(t *testing.T) { db := setupTokenControllerTestDB(t) setupHiddenAutoRouteForTokenTest(t) if err := appi18n.Init(); err != nil { @@ -627,14 +663,49 @@ func TestUpdateTokenRejectsNonSelectableAutoRoute(t *testing.T) { UpdateToken(ctx) response := decodeAPIResponse(t, recorder) - if response.Success { - t.Fatalf("expected hidden auto route token update to fail") + if !response.Success { + t.Fatalf("expected hidden runtime route token update to succeed, got message: %s", response.Message) } - if !strings.Contains(response.Message, "auto:internal") { - t.Fatalf("expected error message to mention hidden route, got %q", response.Message) + + var stored model.Token + if err := db.First(&stored, token.Id).Error; err != nil { + t.Fatalf("failed to reload token: %v", err) } - if !strings.Contains(response.Message, "无权分配") { - t.Fatalf("expected localized denial message, got %q", response.Message) + if stored.Group != "auto:internal" { + t.Fatalf("expected token group auto:internal, got %q", stored.Group) + } +} + +func TestUpdateTokenRejectsUnavailableGroup(t *testing.T) { + db := setupTokenControllerTestDB(t) + if err := appi18n.Init(); err != nil { + t.Fatalf("failed to initialize i18n: %v", err) + } + token := seedToken(t, db, 1, "editable-token", "reject1234group5678") + + body := map[string]any{ + "id": token.Id, + "name": "updated-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": true, + "model_limits_enabled": false, + "model_limits": "", + "group": "missing", + "cross_group_retry": true, + } + + ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1) + setAuthenticatedUserGroup(ctx, "default") + ctx.Request.Header.Set("Accept-Language", "zh-CN") + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected unavailable group token update to fail") + } + if !strings.Contains(response.Message, "missing") { + t.Fatalf("expected error message to mention unavailable group, got %q", response.Message) } var stored model.Token diff --git a/model/log.go b/model/log.go index 87af760c..cafbb66d 100644 --- a/model/log.go +++ b/model/log.go @@ -11,6 +11,7 @@ import ( "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/types" + "github.com/bytedance/gopkg/util/gopool" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -91,6 +92,8 @@ const ( LogFilterEmptyRetry = "empty_retry" ) +var ErrLogRetryMarkerBackfillIncomplete = errors.New("log retry marker backfill is not completed") + const ( LogQuotaFilterAbnormal = "abnormal" LogQuotaFilterZero = "zero" @@ -150,25 +153,28 @@ func applyLogTypeFilter(tx *gorm.DB, logType int) *gorm.DB { var ensureLogRetryMarkerBackfillCompletedForRead = ensureLogRetryMarkerBackfillCompletedForReadDefault -func applyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { +func applyRetryColumnFilter(tx *gorm.DB, column string) (*gorm.DB, error) { if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { return nil, err } - return tx.Where("logs.is_retry = ?", true), nil + switch column { + case "is_retry", "is_error_retry", "is_empty_retry": + return tx.Where("logs."+column+" = ?", true), nil + default: + return nil, fmt.Errorf("invalid retry log filter column: %s", column) + } +} + +func applyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { + return applyRetryColumnFilter(tx, "is_retry") } func applyErrorRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { - if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { - return nil, err - } - return tx.Where("logs.is_error_retry = ?", true), nil + return applyRetryColumnFilter(tx, "is_error_retry") } func applyEmptyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { - if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { - return nil, err - } - return tx.Where("logs.is_empty_retry = ?", true), nil + return applyRetryColumnFilter(tx, "is_empty_retry") } func ensureLogRetryMarkerBackfillCompletedForReadDefault() error { @@ -179,10 +185,17 @@ func ensureLogRetryMarkerBackfillCompletedForReadDefault() error { if completed { return nil } - if err := backfillLogRetryMarker(); err != nil { - return fmt.Errorf("failed to backfill log retry markers before retry log read: %w", err) - } - return nil + return fmt.Errorf("%w; retry log filters will be available after startup backfill finishes", ErrLogRetryMarkerBackfillIncomplete) +} + +var logQuotaDataAsyncRunner = func(fn func()) { + gopool.Go(fn) +} + +func enqueueLogQuotaData(params QuotaDataLogParams) { + logQuotaDataAsyncRunner(func() { + LogQuotaData(params) + }) } func applyLogFilter(tx *gorm.DB, filter string) (*gorm.DB, error) { @@ -577,7 +590,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } if common.DataExportEnabled { createdAt := common.GetTimestamp() - LogQuotaData(QuotaDataLogParams{ + enqueueLogQuotaData(QuotaDataLogParams{ UserID: userId, Username: username, ModelName: params.ModelName, @@ -640,7 +653,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { if nodeName == "" { nodeName = common.NodeName } - LogQuotaData(QuotaDataLogParams{ + enqueueLogQuotaData(QuotaDataLogParams{ UserID: params.UserId, Username: username, ModelName: params.ModelName, diff --git a/model/log_test.go b/model/log_test.go index 36af575e..a1385b47 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -2,11 +2,14 @@ package model import ( "errors" + "net/http" + "net/http/httptest" "os" "testing" "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -24,6 +27,35 @@ func withLogAuditSettings(t *testing.T, requestEnabled bool, responseEnabled boo }) } +func markRetryLogBackfillCompletedForTest(t *testing.T) { + t.Helper() + markerKey := logRetryMarkerBackfillCompletionKey() + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + require.NoError(t, markLogRetryMarkerBackfillCompleted()) + t.Cleanup(func() { + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + }) +} + +func resetQuotaDataCacheForTest(t *testing.T) { + t.Helper() + CacheQuotaDataLock.Lock() + CacheQuotaData = make(map[string]*QuotaData) + CacheQuotaDataLock.Unlock() + t.Cleanup(func() { + CacheQuotaDataLock.Lock() + CacheQuotaData = make(map[string]*QuotaData) + CacheQuotaDataLock.Unlock() + }) +} + +func newLogTestContext() *gin.Context { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + return c +} + func TestLogQuotaFilterIndexes(t *testing.T) { db := newRetryBackfillTestDB(t, &Log{}) @@ -270,6 +302,7 @@ func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { t.Cleanup(func() { require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) }) + markRetryLogBackfillCompletedForTest(t) logs := []Log{ { @@ -313,7 +346,7 @@ func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { require.Equal(t, 16, stat.Tpm) } -func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { +func TestRetryFilterReturnsReadinessErrorBeforeBackfillCompletion(t *testing.T) { markerKey := logRetryMarkerBackfillCompletionKey() require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) @@ -347,23 +380,26 @@ func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { }, } require.NoError(t, LOG_DB.Create(&logs).Error) - require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").Updates(map[string]interface{}{ + "is_retry": false, + "is_error_retry": false, + "is_empty_retry": false, + }).Error) got, total, err := GetAllLogs(LogQueryParams{LogType: LogTypeUnknown, LogFilter: LogFilterRetry, Num: 10}) - require.NoError(t, err) - require.EqualValues(t, 2, total) - require.Len(t, got, 2) - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id}, []int{got[0].Id, got[1].Id}) + require.ErrorIs(t, err, ErrLogRetryMarkerBackfillIncomplete) + require.Nil(t, got) + require.Zero(t, total) var reloaded []Log require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) require.Len(t, reloaded, 2) - require.True(t, reloaded[0].IsRetry) - require.True(t, reloaded[0].IsErrorRetry) + require.False(t, reloaded[0].IsRetry) + require.False(t, reloaded[0].IsErrorRetry) require.False(t, reloaded[0].IsEmptyRetry) - require.True(t, reloaded[1].IsRetry) + require.False(t, reloaded[1].IsRetry) require.False(t, reloaded[1].IsErrorRetry) - require.True(t, reloaded[1].IsEmptyRetry) + require.False(t, reloaded[1].IsEmptyRetry) } func TestRetryFilterUsesIsRetryAfterBackfillCompletion(t *testing.T) { @@ -683,6 +719,85 @@ func TestLogRetryMarkerIsRecomputedWhenOtherChanges(t *testing.T) { require.False(t, reloaded.IsRetry) } +func TestRecordConsumeLogQueuesQuotaDataAsync(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + resetQuotaDataCacheForTest(t) + + originalDataExportEnabled := common.DataExportEnabled + originalRunner := logQuotaDataAsyncRunner + common.DataExportEnabled = true + var queued []func() + executed := false + logQuotaDataAsyncRunner = func(fn func()) { + queued = append(queued, func() { + executed = true + fn() + }) + } + t.Cleanup(func() { + common.DataExportEnabled = originalDataExportEnabled + logQuotaDataAsyncRunner = originalRunner + }) + + RecordConsumeLog(newLogTestContext(), 7, RecordConsumeLogParams{ + ModelName: "gpt-test", + Quota: 42, + PromptTokens: 3, + CompletionTokens: 5, + Group: "default", + TokenId: 11, + ChannelId: 13, + }) + + require.Len(t, queued, 1) + require.False(t, executed) + require.Empty(t, CacheQuotaData) + + queued[0]() + require.True(t, executed) + require.Len(t, CacheQuotaData, 1) +} + +func TestRecordTaskBillingLogQueuesQuotaDataAsync(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + resetQuotaDataCacheForTest(t) + + originalDataExportEnabled := common.DataExportEnabled + originalRunner := logQuotaDataAsyncRunner + common.DataExportEnabled = true + var queued []func() + logQuotaDataAsyncRunner = func(fn func()) { + queued = append(queued, fn) + } + t.Cleanup(func() { + common.DataExportEnabled = originalDataExportEnabled + logQuotaDataAsyncRunner = originalRunner + }) + + RecordTaskBillingLog(RecordTaskBillingLogParams{ + UserId: 7, + LogType: LogTypeConsume, + Content: "task billing", + ChannelId: 13, + ModelName: "task-test", + Quota: 42, + TokenId: 11, + Group: "default", + }) + + require.Len(t, queued, 1) + require.Empty(t, CacheQuotaData) + + queued[0]() + require.Len(t, CacheQuotaData, 1) +} + func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { t.Helper() @@ -703,6 +818,7 @@ func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { func createRetryFilterLogs(t *testing.T) []Log { t.Helper() + markRetryLogBackfillCompletedForTest(t) now := time.Now().Unix() logs := []Log{ diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 0a8fc05a..27ef5ee2 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -28,6 +28,11 @@ const ( streamWriteTimeout = 30 * time.Second ) +var ( + streamPingData = PingData + streamPingWriteTimeout = 10 * time.Second +) + func getScannerBufferSize() int { if constant.StreamScannerMaxBufferMB > 0 { return constant.StreamScannerMaxBufferMB << 20 @@ -41,11 +46,32 @@ func NewStreamScanner(reader io.Reader) *bufio.Scanner { return scanner } -func ExtendWriteDeadline(c *gin.Context) { +func extendWriteDeadline(c *gin.Context, timeout time.Duration) { if c == nil || c.Writer == nil { return } - _ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(streamWriteTimeout)) + _ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(timeout)) +} + +func ExtendWriteDeadline(c *gin.Context) { + extendWriteDeadline(c, streamWriteTimeout) +} + +func pingDataWithTimeout(c *gin.Context) error { + errChan := make(chan error, 1) + go func() { + errChan <- streamPingData(c) + }() + + timer := time.NewTimer(streamPingWriteTimeout) + defer timer.Stop() + + select { + case err := <-errChan: + return err + case <-timer.C: + return fmt.Errorf("ping data timed out after %s", streamPingWriteTimeout) + } } func newStreamingTimeoutTicker(timeoutSeconds int) (*time.Ticker, <-chan time.Time) { @@ -155,12 +181,13 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon func() { writeMutex.Lock() defer writeMutex.Unlock() - ExtendWriteDeadline(c) - err = PingData(c) + extendWriteDeadline(c, streamPingWriteTimeout) + err = pingDataWithTimeout(c) }() if err != nil { logger.LogError(c, "ping data error: "+err.Error()) info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err) + stop() return } logger.LogDebug(c, "ping data sent") diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index bff7ae0a..d21ece30 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -389,8 +389,6 @@ func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) { // ---------- Ping tests ---------- func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) { - t.Parallel() - setting := operation_setting.GetGeneralSetting() oldEnabled := setting.PingIntervalEnabled oldSeconds := setting.PingIntervalSeconds @@ -443,8 +441,6 @@ func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) { } func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) { - t.Parallel() - setting := operation_setting.GetGeneralSetting() oldEnabled := setting.PingIntervalEnabled oldSeconds := setting.PingIntervalSeconds @@ -497,6 +493,84 @@ func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) { assert.Equal(t, 0, pingCount, "pings should be disabled when DisablePing=true") } +func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { + setting := operation_setting.GetGeneralSetting() + oldEnabled := setting.PingIntervalEnabled + oldSeconds := setting.PingIntervalSeconds + setting.PingIntervalEnabled = true + setting.PingIntervalSeconds = 1 + + originalPingData := streamPingData + originalPingTimeout := streamPingWriteTimeout + pingStarted := make(chan struct{}) + releasePing := make(chan struct{}) + var startOnce sync.Once + var releaseOnce sync.Once + streamPingWriteTimeout = 20 * time.Millisecond + streamPingData = func(c *gin.Context) error { + startOnce.Do(func() { + close(pingStarted) + }) + <-releasePing + return nil + } + t.Cleanup(func() { + setting.PingIntervalEnabled = oldEnabled + setting.PingIntervalSeconds = oldSeconds + streamPingData = originalPingData + streamPingWriteTimeout = originalPingTimeout + releaseOnce.Do(func() { + close(releasePing) + }) + }) + + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pw.Close() + }) + body := ¬ifyPipeReadCloser{ + PipeReader: pr, + closed: make(chan struct{}), + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{Body: body} + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + + done := make(chan struct{}) + go func() { + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + close(done) + }() + + select { + case <-pingStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ping to start") + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("stream did not stop after ping timeout") + } + releaseOnce.Do(func() { + close(releasePing) + }) + + require.NotNil(t, info.StreamStatus) + assert.Equal(t, relaycommon.StreamEndReasonPingFail, info.StreamStatus.EndReason) + require.Error(t, info.StreamStatus.EndError) + assert.Contains(t, info.StreamStatus.EndError.Error(), "ping data timed out") + + select { + case <-body.closed: + default: + t.Fatal("upstream response body was not closed after ping timeout") + } +} + // ---------- StreamStatus integration ---------- func TestStreamScannerHandler_StreamStatus_DoneReason(t *testing.T) { @@ -721,8 +795,6 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) { } func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) { - t.Parallel() - setting := operation_setting.GetGeneralSetting() oldEnabled := setting.PingIntervalEnabled oldSeconds := setting.PingIntervalSeconds From 46c5d3dc0e04eb7a621a059c5778025c61453a22 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 17:14:21 +0800 Subject: [PATCH 05/10] v1.0.4 --- main.go | 5 ++- model/log.go | 16 +++++++-- model/log_test.go | 45 ++++++++++++++++++++++++++ relay/helper/stream_scanner.go | 19 +++-------- relay/helper/stream_scanner_test.go | 50 ++++++++++++++++++----------- 5 files changed, 97 insertions(+), 38 deletions(-) diff --git a/main.go b/main.go index 7d4b1398..c53ced27 100644 --- a/main.go +++ b/main.go @@ -236,7 +236,10 @@ func main() { shutdownHTTPServer(ctx, srv) if common.DataExportEnabled { saveTimeout := time.Duration(common.GetEnvOrDefault("QUOTA_DATA_CACHE_SAVE_TIMEOUT_SECONDS", 30)) * time.Second - if !runWithTimeout(saveTimeout, model.SaveQuotaDataCache) { + if !runWithTimeout(saveTimeout, func() { + model.WaitPendingLogQuotaData() + model.SaveQuotaDataCache() + }) { common.SysError(fmt.Sprintf("timed out waiting for quota data cache save after %s", saveTimeout)) } } diff --git a/model/log.go b/model/log.go index cafbb66d..6caf1bc8 100644 --- a/model/log.go +++ b/model/log.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/MAX-API-Next/MAX-API/common" @@ -188,16 +189,25 @@ func ensureLogRetryMarkerBackfillCompletedForReadDefault() error { return fmt.Errorf("%w; retry log filters will be available after startup backfill finishes", ErrLogRetryMarkerBackfillIncomplete) } -var logQuotaDataAsyncRunner = func(fn func()) { - gopool.Go(fn) -} +var ( + logQuotaDataAsyncRunner = func(fn func()) { + gopool.Go(fn) + } + logQuotaDataAsyncWG sync.WaitGroup +) func enqueueLogQuotaData(params QuotaDataLogParams) { + logQuotaDataAsyncWG.Add(1) logQuotaDataAsyncRunner(func() { + defer logQuotaDataAsyncWG.Done() LogQuotaData(params) }) } +func WaitPendingLogQuotaData() { + logQuotaDataAsyncWG.Wait() +} + func applyLogFilter(tx *gorm.DB, filter string) (*gorm.DB, error) { switch filter { case LogFilterRetry: diff --git a/model/log_test.go b/model/log_test.go index a1385b47..93ac4461 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -798,6 +798,51 @@ func TestRecordTaskBillingLogQueuesQuotaDataAsync(t *testing.T) { require.Len(t, CacheQuotaData, 1) } +func TestWaitPendingLogQuotaDataDrainsEnqueuedWork(t *testing.T) { + resetQuotaDataCacheForTest(t) + + originalRunner := logQuotaDataAsyncRunner + release := make(chan struct{}) + logQuotaDataAsyncRunner = func(fn func()) { + go func() { + <-release + fn() + }() + } + t.Cleanup(func() { + logQuotaDataAsyncRunner = originalRunner + }) + + enqueueLogQuotaData(QuotaDataLogParams{ + UserID: 7, + Username: "quota-wait", + ModelName: "gpt-test", + Quota: 42, + CreatedAt: time.Now().Unix(), + }) + + waitDone := make(chan struct{}) + go func() { + WaitPendingLogQuotaData() + close(waitDone) + }() + + select { + case <-waitDone: + t.Fatal("wait returned before queued quota data ran") + case <-time.After(10 * time.Millisecond): + } + + close(release) + + select { + case <-waitDone: + case <-time.After(time.Second): + t.Fatal("timed out waiting for queued quota data") + } + require.Len(t, CacheQuotaData, 1) +} + func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { t.Helper() diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 27ef5ee2..9912d51e 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -58,20 +58,10 @@ func ExtendWriteDeadline(c *gin.Context) { } func pingDataWithTimeout(c *gin.Context) error { - errChan := make(chan error, 1) - go func() { - errChan <- streamPingData(c) - }() - - timer := time.NewTimer(streamPingWriteTimeout) - defer timer.Stop() - - select { - case err := <-errChan: - return err - case <-timer.C: - return fmt.Errorf("ping data timed out after %s", streamPingWriteTimeout) - } + // c.Writer is not safe for concurrent writes; keep the ping write in the + // caller's critical section and rely on the response write deadline to bound it. + extendWriteDeadline(c, streamPingWriteTimeout) + return streamPingData(c) } func newStreamingTimeoutTicker(timeoutSeconds int) (*time.Ticker, <-chan time.Time) { @@ -181,7 +171,6 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon func() { writeMutex.Lock() defer writeMutex.Unlock() - extendWriteDeadline(c, streamPingWriteTimeout) err = pingDataWithTimeout(c) }() if err != nil { diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index d21ece30..2b49753b 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -493,7 +493,7 @@ func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) { assert.Equal(t, 0, pingCount, "pings should be disabled when DisablePing=true") } -func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { +func TestStreamScannerHandler_PingWriteStaysSynchronizedUntilComplete(t *testing.T) { setting := operation_setting.GetGeneralSetting() oldEnabled := setting.PingIntervalEnabled oldSeconds := setting.PingIntervalSeconds @@ -501,12 +501,9 @@ func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { setting.PingIntervalSeconds = 1 originalPingData := streamPingData - originalPingTimeout := streamPingWriteTimeout pingStarted := make(chan struct{}) releasePing := make(chan struct{}) var startOnce sync.Once - var releaseOnce sync.Once - streamPingWriteTimeout = 20 * time.Millisecond streamPingData = func(c *gin.Context) error { startOnce.Do(func() { close(pingStarted) @@ -518,10 +515,6 @@ func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { setting.PingIntervalEnabled = oldEnabled setting.PingIntervalSeconds = oldSeconds streamPingData = originalPingData - streamPingWriteTimeout = originalPingTimeout - releaseOnce.Do(func() { - close(releasePing) - }) }) pr, pw := io.Pipe() @@ -538,9 +531,15 @@ func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { resp := &http.Response{Body: body} info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} + handlerCalled := make(chan struct{}) + var handlerOnce sync.Once done := make(chan struct{}) go func() { - StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) + StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { + handlerOnce.Do(func() { + close(handlerCalled) + }) + }) close(done) }() @@ -550,24 +549,37 @@ func TestStreamScannerHandler_PingTimeoutStopsStream(t *testing.T) { t.Fatal("timed out waiting for ping to start") } + go func() { + fmt.Fprint(pw, "data: chunk_0\n") + fmt.Fprint(pw, "data: [DONE]\n") + _ = pw.Close() + }() + select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("stream did not stop after ping timeout") + case <-handlerCalled: + t.Fatal("data handler ran while ping write was still in progress") + case <-time.After(50 * time.Millisecond): } - releaseOnce.Do(func() { - close(releasePing) - }) + + close(releasePing) require.NotNil(t, info.StreamStatus) - assert.Equal(t, relaycommon.StreamEndReasonPingFail, info.StreamStatus.EndReason) - require.Error(t, info.StreamStatus.EndError) - assert.Contains(t, info.StreamStatus.EndError.Error(), "ping data timed out") + select { + case <-handlerCalled: + case <-time.After(2 * time.Second): + t.Fatal("data handler did not run after ping write completed") + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("stream did not finish after ping write completed") + } select { case <-body.closed: default: - t.Fatal("upstream response body was not closed after ping timeout") + t.Fatal("upstream response body was not closed after stream completion") } } From e833c0e22a295b1c889903aa3a9262348db1de86 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Wed, 8 Jul 2026 18:13:50 +0800 Subject: [PATCH 06/10] v1.0.4 --- controller/audit.go | 3 + controller/redemption.go | 3 +- controller/subscription.go | 86 +++++ model/redemption.go | 39 ++- model/subscription.go | 129 +++++++ model/subscription_reset_test.go | 201 +++++++++++ router/api-router.go | 2 + web/default/index.html | 3 +- .../src/components/ai-elements/context.tsx | 33 +- web/default/src/components/html-content.tsx | 137 +++++++- web/default/src/components/rich-content.tsx | 11 +- .../drawers/channel-mutate-drawer.tsx | 153 +++++++-- web/default/src/features/home/index.tsx | 34 +- .../components/data-table-row-actions.tsx | 11 +- .../src/features/redemption-codes/api.ts | 11 +- .../components/redemptions-table.tsx | 41 ++- .../src/features/redemption-codes/types.ts | 1 + web/default/src/features/subscriptions/api.ts | 25 ++ .../components/data-table-row-actions.tsx | 18 +- .../dialogs/reset-subscriptions-dialog.tsx | 90 +++++ .../dialogs/user-subscriptions-dialog.tsx | 98 +++++- .../components/subscriptions-dialogs.tsx | 2 + .../src/features/subscriptions/types.ts | 23 +- .../custom-oauth/custom-oauth-section.tsx | 42 ++- .../features/system-settings/auth/index.tsx | 1 + .../auth/oauth-callback-url.ts | 35 ++ .../system-settings/auth/oauth-section.tsx | 320 ++++++++++++++---- .../system-settings/auth/section-registry.tsx | 5 +- .../src/features/system-settings/types.ts | 1 + .../components/dialogs/transfer-dialog.tsx | 47 ++- web/default/src/features/wallet/constants.ts | 5 - web/default/src/i18n/locales/en.json | 62 +++- web/default/src/i18n/locales/fr.json | 62 +++- web/default/src/i18n/locales/ja.json | 62 +++- web/default/src/i18n/locales/ru.json | 62 +++- web/default/src/i18n/locales/vi.json | 62 +++- web/default/src/i18n/locales/zh.json | 62 +++- .../src/lib/channel-connection-info.ts | 59 ++++ web/default/src/lib/html-sanitizer.ts | 33 ++ web/default/src/routes/(auth)/sign-up.tsx | 10 +- 40 files changed, 1823 insertions(+), 261 deletions(-) create mode 100644 model/subscription_reset_test.go create mode 100644 web/default/src/features/subscriptions/components/dialogs/reset-subscriptions-dialog.tsx create mode 100644 web/default/src/features/system-settings/auth/oauth-callback-url.ts create mode 100644 web/default/src/lib/channel-connection-info.ts diff --git a/controller/audit.go b/controller/audit.go index 233f3059..fac35107 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -49,6 +49,9 @@ var auditContentTemplates = map[string]string{ "channel.upstream_detect_all": "Detected upstream model changes for channels", "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", + + "subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}", + "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", } func auditContentEN(action string, params map[string]interface{}) string { diff --git a/controller/redemption.go b/controller/redemption.go index 56eb572e..49371266 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -29,8 +29,9 @@ func GetAllRedemptions(c *gin.Context) { func SearchRedemptions(c *gin.Context) { keyword := c.Query("keyword") + status := c.Query("status") pageInfo := common.GetPageQuery(c) - redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + redemptions, total, err := model.SearchRedemptions(keyword, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return diff --git a/controller/subscription.go b/controller/subscription.go index fdcf42a3..cf331b75 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "strconv" "strings" @@ -373,6 +374,28 @@ type AdminCreateUserSubscriptionRequest struct { PlanId int `json:"plan_id"` } +type AdminResetSubscriptionRequest struct { + PlanId int `json:"plan_id"` + AdvanceResetTime *bool `json:"advance_reset_time"` +} + +func resolveAdvanceResetTime(value *bool) bool { + if value == nil { + return true + } + return *value +} + +func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, adminInfo map[string]interface{}) { + if result == nil || result.ResetCount == 0 { + return + } + content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId) + for _, userId := range result.AffectedUserIds { + model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo) + } +} + // AdminCreateUserSubscription creates a new user subscription from a plan (no payment). func AdminCreateUserSubscription(c *gin.Context) { if !requirePaymentCompliance(c) { @@ -401,6 +424,69 @@ func AdminCreateUserSubscription(c *gin.Context) { common.ApiSuccess(c, nil) } +func AdminResetUserSubscriptionsByPlan(c *gin.Context) { + userId, _ := strconv.Atoi(c.Param("id")) + if userId <= 0 { + common.ApiErrorMsg(c, "无效的用户ID") + return + } + var req AdminResetSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime) + result, err := model.AdminResetUserSubscriptionsByPlan(userId, req.PlanId, advanceResetTime) + if err != nil { + common.ApiError(c, err) + return + } + recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) + recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{ + "target_user_id": userId, + "plan_id": result.PlanId, + "plan_title": result.PlanTitle, + "reset_count": result.ResetCount, + "user_count": result.UserCount, + "advance_reset_time": result.AdvanceResetTime, + }) + common.ApiSuccess(c, result) +} + +func AdminResetPlanSubscriptions(c *gin.Context) { + planId, _ := strconv.Atoi(c.Param("id")) + if planId <= 0 { + common.ApiErrorMsg(c, "无效的ID") + return + } + var req AdminResetSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime) + result, err := model.AdminResetPlanSubscriptions(planId, advanceResetTime) + if err != nil { + common.ApiError(c, err) + return + } + recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) + common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t", + result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime)) + recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{ + "plan_id": result.PlanId, + "plan_title": result.PlanTitle, + "reset_count": result.ResetCount, + "user_count": result.UserCount, + "advance_reset_time": result.AdvanceResetTime, + }) + common.ApiSuccess(c, result) +} + // AdminInvalidateUserSubscription cancels a user subscription immediately. func AdminInvalidateUserSubscription(c *gin.Context) { subId, _ := strconv.Atoi(c.Param("id")) diff --git a/model/redemption.go b/model/redemption.go index 629bbe49..a56f0a29 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "strings" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/logger" @@ -60,7 +61,7 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total return redemptions, total, nil } -func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { +func SearchRedemptions(keyword string, status string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { tx := DB.Begin() if tx.Error != nil { return nil, 0, tx.Error @@ -71,14 +72,38 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re } }() - // Build query based on keyword type query := tx.Model(&Redemption{}) + keyword = strings.TrimSpace(keyword) + status = strings.TrimSpace(status) + + if keyword != "" { + if id, err := strconv.Atoi(keyword); err == nil { + query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + } else { + query = query.Where("name LIKE ?", keyword+"%") + } + } - // Only try to convert to ID if the string represents a valid integer - if id, err := strconv.Atoi(keyword); err == nil { - query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") - } else { - query = query.Where("name LIKE ?", keyword+"%") + if status != "" { + now := common.GetTimestamp() + switch status { + case "expired": + query = query.Where( + "status = ? AND expired_time != 0 AND expired_time < ?", + common.RedemptionCodeStatusEnabled, + now, + ) + case strconv.Itoa(common.RedemptionCodeStatusEnabled): + query = query.Where( + "status = ? AND (expired_time = 0 OR expired_time >= ?)", + common.RedemptionCodeStatusEnabled, + now, + ) + case strconv.Itoa(common.RedemptionCodeStatusDisabled): + query = query.Where("status = ?", common.RedemptionCodeStatusDisabled) + case strconv.Itoa(common.RedemptionCodeStatusUsed): + query = query.Where("status = ?", common.RedemptionCodeStatusUsed) + } } // Get total count diff --git a/model/subscription.go b/model/subscription.go index 99cefe82..910f1188 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -342,6 +342,16 @@ type SubscriptionSummary struct { Subscription *UserSubscription `json:"subscription"` } +type SubscriptionResetResult struct { + PlanId int `json:"plan_id"` + MatchedCount int `json:"matched_count"` + ResetCount int `json:"reset_count"` + UserCount int `json:"user_count"` + AdvanceResetTime bool `json:"advance_reset_time"` + PlanTitle string `json:"-"` + AffectedUserIds []int `json:"-"` +} + func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { if plan == nil { return 0, errors.New("plan is nil") @@ -985,6 +995,125 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { return "", nil } +func resetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64, advanceResetTime bool) error { + if tx == nil || sub == nil || plan == nil { + return errors.New("invalid reset args") + } + sub.AmountUsed = 0 + if advanceResetTime { + nextReset := calcNextResetTime(time.Unix(now, 0), plan, sub.EndTime) + sub.NextResetTime = nextReset + if nextReset > 0 { + sub.LastResetTime = now + } else { + sub.LastResetTime = 0 + } + } + return tx.Save(sub).Error +} + +func buildSubscriptionResetResult(plan *SubscriptionPlan, subs []UserSubscription, advanceResetTime bool) *SubscriptionResetResult { + userIds := make([]int, 0, len(subs)) + seenUsers := make(map[int]struct{}, len(subs)) + for _, sub := range subs { + if _, ok := seenUsers[sub.UserId]; ok { + continue + } + seenUsers[sub.UserId] = struct{}{} + userIds = append(userIds, sub.UserId) + } + return &SubscriptionResetResult{ + PlanId: plan.Id, + MatchedCount: len(subs), + ResetCount: len(subs), + UserCount: len(userIds), + AdvanceResetTime: advanceResetTime, + PlanTitle: plan.Title, + AffectedUserIds: userIds, + } +} + +func adminResetUserSubscriptionsByPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) { + if tx == nil || plan == nil { + return nil, errors.New("invalid reset args") + } + var subs []UserSubscription + if err := withRowLock(tx). + Where("user_id = ? AND plan_id = ? AND status = ? AND end_time > ?", userId, plan.Id, "active", now). + Order("end_time asc, id asc"). + Find(&subs).Error; err != nil { + return nil, err + } + if len(subs) == 0 { + return nil, errors.New("该用户没有有效的此套餐订阅") + } + for i := range subs { + if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil { + return nil, err + } + } + return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil +} + +func adminResetPlanSubscriptionsTx(tx *gorm.DB, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) { + if tx == nil || plan == nil { + return nil, errors.New("invalid reset args") + } + var subs []UserSubscription + if err := withRowLock(tx). + Where("plan_id = ? AND status = ? AND end_time > ?", plan.Id, "active", now). + Order("user_id asc, end_time asc, id asc"). + Find(&subs).Error; err != nil { + return nil, err + } + for i := range subs { + if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil { + return nil, err + } + } + return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil +} + +func AdminResetUserSubscriptionsByPlan(userId int, planId int, advanceResetTime bool) (*SubscriptionResetResult, error) { + if userId <= 0 || planId <= 0 { + return nil, errors.New("invalid userId or planId") + } + var result *SubscriptionResetResult + now := GetDBTimestamp() + err := DB.Transaction(func(tx *gorm.DB) error { + plan, err := getSubscriptionPlanByIdTx(tx, planId) + if err != nil { + return err + } + result, err = adminResetUserSubscriptionsByPlanTx(tx, userId, plan, now, advanceResetTime) + return err + }) + if err != nil { + return nil, err + } + return result, nil +} + +func AdminResetPlanSubscriptions(planId int, advanceResetTime bool) (*SubscriptionResetResult, error) { + if planId <= 0 { + return nil, errors.New("invalid planId") + } + var result *SubscriptionResetResult + now := GetDBTimestamp() + err := DB.Transaction(func(tx *gorm.DB) error { + plan, err := getSubscriptionPlanByIdTx(tx, planId) + if err != nil { + return err + } + result, err = adminResetPlanSubscriptionsTx(tx, plan, now, advanceResetTime) + return err + }) + if err != nil { + return nil, err + } + return result, nil +} + type SubscriptionPreConsumeResult struct { UserSubscriptionId int PreConsumed int64 diff --git a/model/subscription_reset_test.go b/model/subscription_reset_test.go new file mode 100644 index 00000000..338f18a7 --- /dev/null +++ b/model/subscription_reset_test.go @@ -0,0 +1,201 @@ +package model + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedSubscriptionResetPlan(t *testing.T, plan *SubscriptionPlan) { + t.Helper() + require.NoError(t, DB.Create(plan).Error) +} + +func seedSubscriptionResetSub(t *testing.T, sub *UserSubscription) { + t.Helper() + require.NoError(t, DB.Create(sub).Error) +} + +func getSubscriptionResetSub(t *testing.T, id int) UserSubscription { + t.Helper() + var sub UserSubscription + require.NoError(t, DB.Where("id = ?", id).First(&sub).Error) + return sub +} + +func TestAdminResetUserSubscriptionsByPlanResetsAllActiveMatchesAndAdvancesTime(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9101, + Title: "Pro", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + QuotaResetPeriod: SubscriptionResetDaily, + } + otherPlan := &SubscriptionPlan{ + Id: 9102, + Title: "Basic", + PriceAmount: 1, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 100, + QuotaResetPeriod: SubscriptionResetDaily, + } + seedSubscriptionResetPlan(t, plan) + seedSubscriptionResetPlan(t, otherPlan) + + activeEnd := now + 30*24*3600 + expiredEnd := now - 1 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9201, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 300, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9202, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9203, UserId: 101, PlanId: otherPlan.Id, AmountTotal: 100, AmountUsed: 60, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9204, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 700, StartTime: now - 7200, EndTime: expiredEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9205, UserId: 102, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 800, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9206, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 900, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 120}) + + beforeReset := GetDBTimestamp() + result, err := AdminResetUserSubscriptionsByPlan(101, plan.Id, true) + afterReset := GetDBTimestamp() + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, plan.Id, result.PlanId) + assert.Equal(t, 2, result.MatchedCount) + assert.Equal(t, 2, result.ResetCount) + assert.Equal(t, 1, result.UserCount) + assert.Equal(t, []int{101}, result.AffectedUserIds) + assert.True(t, result.AdvanceResetTime) + + for _, id := range []int{9201, 9202} { + sub := getSubscriptionResetSub(t, id) + assert.Zero(t, sub.AmountUsed) + assert.GreaterOrEqual(t, sub.LastResetTime, beforeReset) + assert.LessOrEqual(t, sub.LastResetTime, afterReset) + assert.Equal(t, calcNextResetTime(time.Unix(sub.LastResetTime, 0), plan, sub.EndTime), sub.NextResetTime) + } + assert.EqualValues(t, 60, getSubscriptionResetSub(t, 9203).AmountUsed) + assert.EqualValues(t, 700, getSubscriptionResetSub(t, 9204).AmountUsed) + assert.EqualValues(t, 800, getSubscriptionResetSub(t, 9205).AmountUsed) + assert.EqualValues(t, 900, getSubscriptionResetSub(t, 9206).AmountUsed) +} + +func TestAdminResetUserSubscriptionsByPlanKeepsResetTimes(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9301, + Title: "Team", + PriceAmount: 20, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 2000, + QuotaResetPeriod: SubscriptionResetMonthly, + } + seedSubscriptionResetPlan(t, plan) + + lastReset := now - 86400 + nextReset := now + 86400 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9302, UserId: 201, PlanId: plan.Id, AmountTotal: 2000, AmountUsed: 1200, StartTime: now - 172800, EndTime: now + 30*24*3600, Status: "active", LastResetTime: lastReset, NextResetTime: nextReset}) + + result, err := AdminResetUserSubscriptionsByPlan(201, plan.Id, false) + + require.NoError(t, err) + assert.False(t, result.AdvanceResetTime) + sub := getSubscriptionResetSub(t, 9302) + assert.Zero(t, sub.AmountUsed) + assert.Equal(t, lastReset, sub.LastResetTime) + assert.Equal(t, nextReset, sub.NextResetTime) +} + +func TestAdminResetUserSubscriptionsByPlanNoActiveMatchReturnsError(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9401, + Title: "Expired", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + } + seedSubscriptionResetPlan(t, plan) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9402, UserId: 301, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 7200, EndTime: now - 1, Status: "active"}) + + result, err := AdminResetUserSubscriptionsByPlan(301, plan.Id, true) + + require.Error(t, err) + assert.Nil(t, result) + assert.True(t, strings.Contains(err.Error(), "该用户没有有效的此套餐订阅")) +} + +func TestAdminResetPlanSubscriptionsResetsAllActiveUsers(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9501, + Title: "Business", + PriceAmount: 30, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 3000, + QuotaResetPeriod: SubscriptionResetNever, + } + seedSubscriptionResetPlan(t, plan) + + activeEnd := now + 30*24*3600 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9502, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1000, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9503, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1100, StartTime: now - 3500, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9504, UserId: 402, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1200, StartTime: now - 3400, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9505, UserId: 403, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1300, StartTime: now - 7200, EndTime: now - 1, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9506, UserId: 404, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1400, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 10}) + + result, err := AdminResetPlanSubscriptions(plan.Id, true) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 3, result.MatchedCount) + assert.Equal(t, 3, result.ResetCount) + assert.Equal(t, 2, result.UserCount) + assert.Equal(t, []int{401, 402}, result.AffectedUserIds) + for _, id := range []int{9502, 9503, 9504} { + sub := getSubscriptionResetSub(t, id) + assert.Zero(t, sub.AmountUsed) + assert.Zero(t, sub.LastResetTime) + assert.Zero(t, sub.NextResetTime) + } + assert.EqualValues(t, 1300, getSubscriptionResetSub(t, 9505).AmountUsed) + assert.EqualValues(t, 1400, getSubscriptionResetSub(t, 9506).AmountUsed) +} + +func TestAdminResetPlanSubscriptionsNoMatchSucceeds(t *testing.T) { + truncateTables(t) + + plan := &SubscriptionPlan{ + Id: 9601, + Title: "Empty", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + } + seedSubscriptionResetPlan(t, plan) + + result, err := AdminResetPlanSubscriptions(plan.Id, true) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Zero(t, result.MatchedCount) + assert.Zero(t, result.ResetCount) + assert.Zero(t, result.UserCount) + assert.Empty(t, result.AffectedUserIds) +} diff --git a/router/api-router.go b/router/api-router.go index 284d1bf7..d8d58f0c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -168,10 +168,12 @@ func SetApiRouter(router *gin.Engine) { subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus) subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) + subscriptionAdminRoute.POST("/plans/:id/subscriptions/reset", controller.AdminResetPlanSubscriptions) // User subscription management (admin) subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions) subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription) + subscriptionAdminRoute.POST("/users/:id/subscriptions/reset", controller.AdminResetUserSubscriptionsByPlan) subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription) subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription) } diff --git a/web/default/index.html b/web/default/index.html index 9979e766..51da6ee8 100644 --- a/web/default/index.html +++ b/web/default/index.html @@ -4,6 +4,7 @@ + MAX API @@ -19,6 +20,6 @@ -
+
diff --git a/web/default/src/components/ai-elements/context.tsx b/web/default/src/components/ai-elements/context.tsx index 115d9ef1..6c76d690 100644 --- a/web/default/src/components/ai-elements/context.tsx +++ b/web/default/src/components/ai-elements/context.tsx @@ -46,6 +46,35 @@ type ContextSchema = { modelId?: ModelId } +type UsageWithTokenDetails = LanguageModelUsage & { + reasoningTokens?: number + cachedInputTokens?: number + outputTokenDetails?: { + reasoningTokens?: number + } + inputTokenDetails?: { + cacheReadTokens?: number + } +} + +function getReasoningTokenCount(usage?: LanguageModelUsage): number { + const tokenUsage = usage as UsageWithTokenDetails | undefined + return ( + tokenUsage?.outputTokenDetails?.reasoningTokens ?? + tokenUsage?.reasoningTokens ?? + 0 + ) +} + +function getCacheReadTokenCount(usage?: LanguageModelUsage): number { + const tokenUsage = usage as UsageWithTokenDetails | undefined + return ( + tokenUsage?.inputTokenDetails?.cacheReadTokens ?? + tokenUsage?.cachedInputTokens ?? + 0 + ) +} + const ContextContext = createContext(null) const useContextValue = () => { @@ -346,7 +375,7 @@ export const ContextReasoningUsage = ({ }: ContextReasoningUsageProps) => { const { t } = useTranslation() const { usage, modelId } = useContextValue() - const reasoningTokens = usage?.reasoningTokens ?? 0 + const reasoningTokens = getReasoningTokenCount(usage) if (children) { return children @@ -387,7 +416,7 @@ export const ContextCacheUsage = ({ }: ContextCacheUsageProps) => { const { t } = useTranslation() const { usage, modelId } = useContextValue() - const cacheTokens = usage?.cachedInputTokens ?? 0 + const cacheTokens = getCacheReadTokenCount(usage) if (children) { return children diff --git a/web/default/src/components/html-content.tsx b/web/default/src/components/html-content.tsx index 3ca2aeeb..fda86c61 100644 --- a/web/default/src/components/html-content.tsx +++ b/web/default/src/components/html-content.tsx @@ -16,20 +16,145 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useMemo } from 'react' -import { sanitizeHtmlContent } from '@/lib/html-sanitizer' +import { useEffect, useMemo, useRef, type ReactElement } from 'react' +import { + sanitizeHtmlContent, + sanitizeIsolatedHtmlContent, +} from '@/lib/html-sanitizer' import { cn } from '@/lib/utils' +export type HtmlContentVariant = 'inline' | 'isolated' + interface HtmlContentProps { content: string className?: string + variant?: HtmlContentVariant } -export function HtmlContent(props: HtmlContentProps) { - const html = useMemo( - () => sanitizeHtmlContent(props.content), - [props.content] +const isolatedContentSandbox = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation' + +const isolatedContentBaseStyles = ` + +` + +function hardenIsolatedHtml(html: string): string { + if (typeof document === 'undefined') { + return html + } + + const template = document.createElement('template') + template.innerHTML = html + + template.content.querySelectorAll('a[target="_blank"]').forEach((link) => { + const rel = new Set( + link.getAttribute('rel')?.split(/\s+/).filter(Boolean) ?? [] + ) + + rel.add('noopener') + rel.add('noreferrer') + link.setAttribute('rel', [...rel].join(' ')) + }) + + template.content.querySelectorAll('iframe').forEach((frame) => { + frame.removeAttribute('srcdoc') + frame.setAttribute('sandbox', isolatedContentSandbox) + frame.setAttribute('referrerpolicy', 'no-referrer') + + if (!frame.hasAttribute('loading')) { + frame.setAttribute('loading', 'lazy') + } + }) + + return template.innerHTML +} + +function syncDarkClass(wrapper: HTMLElement): void { + const isDark = document.documentElement.classList.contains('dark') + wrapper.classList.toggle('dark', isDark) +} + +function IsolatedHtmlContent(props: { + className?: string + html: string +}): ReactElement { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }) + const applicationStyleNodes = [ + ...document.head.querySelectorAll( + 'style, link[rel="stylesheet"]' + ), + ].map((node) => node.cloneNode(true)) + + const wrapper = document.createElement('div') + syncDarkClass(wrapper) + wrapper.innerHTML = props.html + + const contentTemplate = document.createElement('template') + contentTemplate.innerHTML = isolatedContentBaseStyles + + shadowRoot.replaceChildren( + ...applicationStyleNodes, + contentTemplate.content, + wrapper + ) + + const observer = new MutationObserver(() => syncDarkClass(wrapper)) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + + return () => observer.disconnect() + }, [props.html]) + + return ( +
) +} + +export function HtmlContent(props: HtmlContentProps) { + const variant = props.variant ?? 'inline' + const html = useMemo(() => { + if (variant === 'isolated') { + return hardenIsolatedHtml(sanitizeIsolatedHtmlContent(props.content)) + } + + return sanitizeHtmlContent(props.content) + }, [props.content, variant]) + + if (variant === 'isolated') { + return + } return (
. For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { HtmlContent } from '@/components/html-content' import { Markdown } from '@/components/ui/markdown' +import { HtmlContent, type HtmlContentVariant } from '@/components/html-content' type RichContentMode = 'markdown' | 'html' @@ -25,11 +25,18 @@ interface RichContentProps { content: string mode?: RichContentMode className?: string + htmlVariant?: HtmlContentVariant } export function RichContent(props: RichContentProps) { if (props.mode === 'html') { - return + return ( + + ) } return {props.content} diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index f391d65b..3500e703 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -33,6 +33,7 @@ import { Boxes, CheckCircle2, Circle, + ClipboardPaste, HelpCircle, Loader2, Server, @@ -54,6 +55,10 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + parseChannelConnectionInfo, + type ChannelConnectionInfo, +} from '@/lib/channel-connection-info' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -589,6 +594,8 @@ export function ChannelMutateDrawer({ const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false) const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] = useState(false) + const [clipboardConnectionInfo, setClipboardConnectionInfo] = + useState(null) const isEditing = Boolean(currentRow) const channelId = currentRow?.id ?? null @@ -721,6 +728,67 @@ export function ChannelMutateDrawer({ } }, [open, resetDoubaoApiUnlock]) + const applyConnectionInfo = useCallback( + (connectionInfo: ChannelConnectionInfo) => { + form.setValue('key', connectionInfo.key, { + shouldDirty: true, + shouldValidate: true, + }) + form.setValue('base_url', connectionInfo.url, { + shouldDirty: true, + shouldValidate: true, + }) + setClipboardConnectionInfo(null) + toast.success(t('Connection info filled in')) + }, + [form, t] + ) + + const pasteConnectionInfoFromClipboard = useCallback(async () => { + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) { + toast.error(t('Unable to read clipboard')) + return + } + + try { + const text = await navigator.clipboard.readText() + const parsed = parseChannelConnectionInfo(text) + if (parsed) { + applyConnectionInfo(parsed) + return + } + toast.info(t('No connection info found in clipboard')) + } catch { + toast.error(t('Unable to read clipboard')) + } + }, [applyConnectionInfo, t]) + + useEffect(() => { + if (!open || isEditing) { + setClipboardConnectionInfo(null) + return + } + + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) { + return + } + + let cancelled = false + void navigator.clipboard + .readText() + .then((text) => { + if (cancelled) return + setClipboardConnectionInfo(parseChannelConnectionInfo(text)) + }) + .catch(() => { + /* Clipboard detection is best-effort on drawer open. */ + }) + + return () => { + cancelled = true + } + }, [isEditing, open]) + // Helper computed values const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' @@ -1669,6 +1737,7 @@ export function ChannelMutateDrawer({ setActiveEditorSectionId(CHANNEL_EDITOR_SECTION_IDS.identity) setExpandedEditorNavItemId(undefined) setAdvancedSettingsOpen(false) + setClipboardConnectionInfo(null) } }, [onOpenChange, form] @@ -1679,28 +1748,72 @@ export function ChannelMutateDrawer({ - - - {getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)} - - - {isEditing ? t('Edit Channel') : t('Create Channel')} - - {t(currentTypeLabel)} - - - - - {isEditing - ? t( - "Update channel configuration and click save when you're done." - ) - : t( - 'Add a new channel by providing the necessary information.' - )} - +
+
+ + + {getLobeIcon( + `${getChannelTypeIcon(currentType)}.Color`, + 22 + )} + + + {isEditing ? t('Edit Channel') : t('Create Channel')} + + {t(currentTypeLabel)} + + + + + {isEditing + ? t( + "Update channel configuration and click save when you're done." + ) + : t( + 'Add a new channel by providing the necessary information.' + )} + +
+ {!isEditing && ( + + )} +
+ {!isEditing && clipboardConnectionInfo && ( + + + {t('Connection info detected in clipboard')} + + + + + + + )} +
. For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { RichContent } from '@/components/rich-content' import { useAuthStore } from '@/stores/auth-store' import { getRenderableContentKind, getSafeIframeEmbedSrc, } from '@/lib/renderable-content' +import { useTheme } from '@/context/theme-provider' import { PublicLayout } from '@/components/layout' import { Footer } from '@/components/layout/components/footer' +import { RichContent } from '@/components/rich-content' import { CTA, Features, Hero, HowItWorks, Stats } from './components' import { useHomePageContent } from './hooks' export function Home() { - const { t } = useTranslation() + const { i18n, t } = useTranslation() + const iframeRef = useRef(null) + const { resolvedTheme } = useTheme() const { auth } = useAuthStore() const isAuthenticated = !!auth.user const { content, isLoaded } = useHomePageContent() @@ -37,6 +41,27 @@ export function Home() { const contentKind = getRenderableContentKind(customContent) const iframeEmbedSrc = getSafeIframeEmbedSrc(customContent) + const syncIframePreferences = useCallback(() => { + try { + iframeRef.current?.contentWindow?.postMessage( + { themeMode: resolvedTheme }, + '*' + ) + iframeRef.current?.contentWindow?.postMessage( + { lang: i18n.language }, + '*' + ) + } catch { + // Cross-origin frames can reject access while navigating. + } + }, [i18n.language, resolvedTheme]) + + useEffect(() => { + if (iframeEmbedSrc) { + syncIframePreferences() + } + }, [iframeEmbedSrc, syncIframePreferences]) + if (!isLoaded) { return ( @@ -53,10 +78,12 @@ export function Home() {
{iframeEmbedSrc ? (